feat(backend): appareils appairés persistants, révocables et code éphémère (#77 B1-B4)
L'appairage ne survivait pas au redémarrage et son code, permanent, était imprimé sur la sortie standard. Un appareil appairé devient une entité persistante, nommée et révocable, derrière un code désormais éphémère. - B1 : port DeviceSessionStore et adapter FsDeviceSessionStore, entités de domaine (PairedDevice, DeviceId, SessionTokenHash, DeviceName). Les tokens sont hachés en SHA-256 et comparés en temps constant (subtle) : le store ne peut pas rejouer une session qu'il a servie. Cookie Max-Age 400 j à renouvellement glissant, lastSeenAtMs throttlé. - B2 : code éphémère en mémoire, TTL 10 min et usage unique, toute génération invalidant la précédente. POST /api/pairing-code authentifiée, flag --new-code. Le code est retiré du boot et l'eprintln! qui l'imprimait est supprimé. - B3 : endpoints devices (list/rename/revoke/revoke-all/logout), event DeviceRevoked et ActiveConnectionRegistry par device_id, fermant sans délai les WebSockets d'un appareil révoqué. - B4 : port PairAttemptLimiter et adapter mémoire, rate-limit par origine et global sur horloge injectée, donc testable sans attente réelle. La normalisation du code passe côté serveur : elle absorbe la dette #76, que la seule normalisation frontend de #75 ne faisait que masquer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
8
Cargo.lock
generated
8
Cargo.lock
generated
@ -101,6 +101,7 @@ dependencies = [
|
|||||||
"domain",
|
"domain",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"subtle",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
@ -905,8 +906,11 @@ name = "domain"
|
|||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"hex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"subtle",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
@ -5247,10 +5251,14 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"cookie",
|
"cookie",
|
||||||
"domain",
|
"domain",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"hex",
|
||||||
"http",
|
"http",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
"subtle",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -22,6 +22,10 @@ thiserror = "2"
|
|||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time"] }
|
||||||
|
hex = "0.4"
|
||||||
|
sha2 = "0.10"
|
||||||
|
subtle = "2"
|
||||||
|
getrandom = "0.3"
|
||||||
# Local git via libgit2. Network features (https/ssh → openssl) are off for L8:
|
# Local git via libgit2. Network features (https/ssh → openssl) are off for L8:
|
||||||
# only local operations (status/commit/branch/checkout/log) are in scope; remote
|
# only local operations (status/commit/branch/checkout/log) are in scope; remote
|
||||||
# push/pull and static vendoring for the AppImage are deferred to L9/L11.
|
# push/pull and static vendoring for the AppImage are deferred to L9/L11.
|
||||||
|
|||||||
@ -16,12 +16,12 @@ use application::{
|
|||||||
DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput,
|
DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput,
|
||||||
GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput,
|
GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput,
|
||||||
GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput,
|
GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput,
|
||||||
LaunchAgentInput, ListAgentsInput, ListLayoutsInput, ListMemoriesInput,
|
LaunchAgentInput, ListAgentsInput, ListDevicesInput, ListLayoutsInput, ListMemoriesInput,
|
||||||
ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime,
|
ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime,
|
||||||
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput,
|
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput,
|
||||||
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
|
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
|
||||||
ReconcileLiveStateInput, RenameLayoutInput, ResolveAgentPermissionsInput,
|
ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput,
|
||||||
ResolveMemoryLinksInput, RotateConversationLogInput, SetActiveLayoutInput,
|
ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput,
|
||||||
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
|
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
|
||||||
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput,
|
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput,
|
||||||
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
|
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
|
||||||
@ -66,7 +66,8 @@ use crate::embedded_server::{
|
|||||||
};
|
};
|
||||||
use crate::pty::{PtyBridge, PtyChunk};
|
use crate::pty::{PtyBridge, PtyChunk};
|
||||||
use crate::state::{AppState, FocusedProjectDto};
|
use crate::state::{AppState, FocusedProjectDto};
|
||||||
use domain::{SkillRef, SkillScope};
|
use domain::{DeviceId, SkillRef, SkillScope};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// `health` — trivial command validating the full IPC pipeline
|
/// `health` — trivial command validating the full IPC pipeline
|
||||||
/// (frontend gateway → invoke → command → use case → ports → event relay).
|
/// (frontend gateway → invoke → command → use case → ports → event relay).
|
||||||
@ -149,6 +150,157 @@ pub async fn embedded_server_stop(
|
|||||||
state.embedded_server.stop().await
|
state.embedded_server.stop().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `embedded_server_generate_pairing_code` — generate a desktop-owned ephemeral code.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] when the embedded server is not running.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn embedded_server_generate_pairing_code(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<web_server::PairingCodeDto, ErrorDto> {
|
||||||
|
state.embedded_server.generate_pairing_code()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Device row exposed to the desktop settings surface.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DeviceDto {
|
||||||
|
/// Device id.
|
||||||
|
pub device_id: String,
|
||||||
|
/// User-facing name.
|
||||||
|
pub name: String,
|
||||||
|
/// Pairing timestamp as epoch milliseconds.
|
||||||
|
pub paired_at_ms: u64,
|
||||||
|
/// Last successful access timestamp as epoch milliseconds.
|
||||||
|
pub last_seen_at_ms: u64,
|
||||||
|
/// Whether this is the current authenticated web device.
|
||||||
|
pub is_current_device: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List response for paired devices.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct DeviceListDto {
|
||||||
|
/// Paired devices.
|
||||||
|
pub devices: Vec<DeviceDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rename request for paired devices.
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RenameDeviceRequestDto {
|
||||||
|
/// Device id.
|
||||||
|
pub device_id: String,
|
||||||
|
/// New name.
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revoke request for one paired device.
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RevokeDeviceRequestDto {
|
||||||
|
/// Device id.
|
||||||
|
pub device_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_device_id(raw: &str) -> Result<DeviceId, ErrorDto> {
|
||||||
|
Uuid::parse_str(raw)
|
||||||
|
.map(DeviceId::from_uuid)
|
||||||
|
.map_err(|_| ErrorDto {
|
||||||
|
code: "INVALID".to_owned(),
|
||||||
|
message: "invalid device id".to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `list_devices` — list paired devices through the desktop composition root.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] when the device store cannot be read.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_devices(state: State<'_, AppState>) -> Result<DeviceListDto, ErrorDto> {
|
||||||
|
let output = state
|
||||||
|
.list_devices
|
||||||
|
.execute(ListDevicesInput {
|
||||||
|
current_device_id: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)?;
|
||||||
|
Ok(DeviceListDto {
|
||||||
|
devices: output
|
||||||
|
.devices
|
||||||
|
.into_iter()
|
||||||
|
.map(|device| DeviceDto {
|
||||||
|
device_id: device.device_id.to_string(),
|
||||||
|
name: device.name,
|
||||||
|
paired_at_ms: device.paired_at_ms,
|
||||||
|
last_seen_at_ms: device.last_seen_at_ms,
|
||||||
|
is_current_device: device.is_current_device,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `create_pairing_code` — generate an ephemeral pairing code via the embedded server state.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] when the embedded server is not running.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn create_pairing_code(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<web_server::PairingCodeDto, ErrorDto> {
|
||||||
|
state.embedded_server.generate_pairing_code()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `rename_device` — rename a paired device.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] for invalid input or store failures.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn rename_device(
|
||||||
|
request: RenameDeviceRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), ErrorDto> {
|
||||||
|
state
|
||||||
|
.rename_device
|
||||||
|
.execute(RenameDeviceInput {
|
||||||
|
device_id: parse_device_id(&request.device_id)?,
|
||||||
|
name: request.name,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `revoke_device` — revoke one paired device.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] for invalid input or store failures.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn revoke_device(
|
||||||
|
request: RevokeDeviceRequestDto,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), ErrorDto> {
|
||||||
|
state
|
||||||
|
.revoke_device
|
||||||
|
.execute(RevokeDeviceInput {
|
||||||
|
device_id: parse_device_id(&request.device_id)?,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `revoke_all_devices` — revoke every paired device.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] when the device store cannot be rewritten.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn revoke_all_devices(state: State<'_, AppState>) -> Result<(), ErrorDto> {
|
||||||
|
state
|
||||||
|
.revoke_all_devices
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.map_err(ErrorDto::from)
|
||||||
|
}
|
||||||
|
|
||||||
/// `create_project` — create a project from a root: init `.ideai/`, register it.
|
/// `create_project` — create a project from a root: init `.ideai/`, register it.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
|
|||||||
@ -10,7 +10,7 @@ use std::sync::{Arc, Mutex};
|
|||||||
|
|
||||||
use backend::BackendCore;
|
use backend::BackendCore;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use web_server::{EmbeddedServerHandle, ServerConfig, TrustedProxy};
|
use web_server::{EmbeddedServerHandle, PairingCodeDto, ServerConfig, TrustedProxy};
|
||||||
|
|
||||||
use crate::dto::ErrorDto;
|
use crate::dto::ErrorDto;
|
||||||
|
|
||||||
@ -93,8 +93,6 @@ pub struct EmbeddedServerStatusDto {
|
|||||||
pub public_url: Option<String>,
|
pub public_url: Option<String>,
|
||||||
/// Reverse-proxy upstream URL derived from settings.
|
/// Reverse-proxy upstream URL derived from settings.
|
||||||
pub upstream_url: Option<String>,
|
pub upstream_url: Option<String>,
|
||||||
/// Runtime pairing code, never persisted.
|
|
||||||
pub pairing_code: Option<String>,
|
|
||||||
/// Last failure, when state is `failed`.
|
/// Last failure, when state is `failed`.
|
||||||
pub error: Option<ErrorDto>,
|
pub error: Option<ErrorDto>,
|
||||||
}
|
}
|
||||||
@ -278,6 +276,21 @@ impl EmbeddedServerController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generates a new ephemeral pairing code on the running embedded server.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an [`ErrorDto`] if the embedded server is not running.
|
||||||
|
pub fn generate_pairing_code(&self) -> Result<PairingCodeDto, ErrorDto> {
|
||||||
|
let inner = self.inner.lock().expect("embedded server mutex poisoned");
|
||||||
|
let Some(handle) = inner.handle.as_ref() else {
|
||||||
|
return Err(ErrorDto {
|
||||||
|
code: "UNAVAILABLE".to_owned(),
|
||||||
|
message: "embedded server is not running".to_owned(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
Ok(handle.generate_pairing_code())
|
||||||
|
}
|
||||||
|
|
||||||
/// Stops the embedded server. Idempotent when already stopped.
|
/// Stops the embedded server. Idempotent when already stopped.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@ -331,10 +344,6 @@ fn status_from_inner(inner: &EmbeddedServerInner) -> EmbeddedServerStatusDto {
|
|||||||
local_url: inner.handle.as_ref().map(|handle| handle.url().to_owned()),
|
local_url: inner.handle.as_ref().map(|handle| handle.url().to_owned()),
|
||||||
public_url: inner.public_url.clone(),
|
public_url: inner.public_url.clone(),
|
||||||
upstream_url: inner.upstream_url.clone(),
|
upstream_url: inner.upstream_url.clone(),
|
||||||
pairing_code: inner
|
|
||||||
.handle
|
|
||||||
.as_ref()
|
|
||||||
.map(|handle| handle.pairing_code().to_owned()),
|
|
||||||
error,
|
error,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -436,6 +445,7 @@ fn server_config_from_settings(
|
|||||||
trusted_proxies,
|
trusted_proxies,
|
||||||
app_data_dir,
|
app_data_dir,
|
||||||
web_root,
|
web_root,
|
||||||
|
new_code: false,
|
||||||
};
|
};
|
||||||
config.validate().map_err(invalid_error)?;
|
config.validate().map_err(invalid_error)?;
|
||||||
Ok(config)
|
Ok(config)
|
||||||
@ -771,7 +781,9 @@ mod tests {
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.is_some_and(|url| url.starts_with("http://127.0.0.1:")));
|
.is_some_and(|url| url.starts_with("http://127.0.0.1:")));
|
||||||
assert_ne!(first.local_url.as_deref(), Some("http://127.0.0.1:0"));
|
assert_ne!(first.local_url.as_deref(), Some("http://127.0.0.1:0"));
|
||||||
assert_eq!(first.pairing_code, second.pairing_code);
|
let pairing = controller.generate_pairing_code().unwrap();
|
||||||
|
assert_eq!(pairing.ttl_seconds, 600);
|
||||||
|
assert_eq!(pairing.code.len(), 8);
|
||||||
|
|
||||||
let stopped = controller.stop().await.unwrap();
|
let stopped = controller.stop().await.unwrap();
|
||||||
|
|
||||||
@ -780,7 +792,6 @@ mod tests {
|
|||||||
EmbeddedServerStatusStateDto::Stopped
|
EmbeddedServerStatusStateDto::Stopped
|
||||||
));
|
));
|
||||||
assert!(stopped.local_url.is_none());
|
assert!(stopped.local_url.is_none());
|
||||||
assert!(stopped.pairing_code.is_none());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@ -804,6 +815,5 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(err.code, "INVALID");
|
assert_eq!(err.code, "INVALID");
|
||||||
assert!(matches!(status.state, EmbeddedServerStatusStateDto::Failed));
|
assert!(matches!(status.state, EmbeddedServerStatusStateDto::Failed));
|
||||||
assert!(status.pairing_code.is_none());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -292,6 +292,12 @@ pub fn run() {
|
|||||||
commands::embedded_server_status,
|
commands::embedded_server_status,
|
||||||
commands::embedded_server_start,
|
commands::embedded_server_start,
|
||||||
commands::embedded_server_stop,
|
commands::embedded_server_stop,
|
||||||
|
commands::embedded_server_generate_pairing_code,
|
||||||
|
commands::list_devices,
|
||||||
|
commands::create_pairing_code,
|
||||||
|
commands::rename_device,
|
||||||
|
commands::revoke_device,
|
||||||
|
commands::revoke_all_devices,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running IdeA Tauri application");
|
.expect("error while running IdeA Tauri application");
|
||||||
|
|||||||
@ -12,6 +12,7 @@ thiserror = { workspace = true }
|
|||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
subtle = { workspace = true }
|
||||||
# `v5` derives stable reference-profile ids from a fixed namespace (catalogue).
|
# `v5` derives stable reference-profile ids from a fixed namespace (catalogue).
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
# `time` feature only : borne le rendez-vous synchrone `send_blocking` (§17.4).
|
# `time` feature only : borne le rendez-vous synchrone `send_blocking` (§17.4).
|
||||||
|
|||||||
482
crates/application/src/device.rs
Normal file
482
crates/application/src/device.rs
Normal file
@ -0,0 +1,482 @@
|
|||||||
|
//! Paired-device session use cases.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::events::DomainEvent;
|
||||||
|
use domain::ports::{Clock, DeviceSessionStore, EventBus, IdGenerator, StoreError};
|
||||||
|
use domain::{AuthenticatedDevice, DeviceId, DeviceName, PairedDevice, SessionTokenHash};
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
const LAST_SEEN_TOUCH_THROTTLE_MS: u64 = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/// Input for [`PairDevice`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PairDeviceInput {
|
||||||
|
/// Name supplied by the new device.
|
||||||
|
pub name: String,
|
||||||
|
/// Hash of the freshly issued session token.
|
||||||
|
pub session_token_hash: SessionTokenHash,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output for [`PairDevice`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PairDeviceOutput {
|
||||||
|
/// Created paired device.
|
||||||
|
pub device: PairedDevice,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a pairing request and persists the new paired device.
|
||||||
|
pub struct PairDevice {
|
||||||
|
store: Arc<dyn DeviceSessionStore>,
|
||||||
|
ids: Arc<dyn IdGenerator>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PairDevice {
|
||||||
|
/// Builds the use case from injected ports.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
store: Arc<dyn DeviceSessionStore>,
|
||||||
|
ids: Arc<dyn IdGenerator>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
) -> Self {
|
||||||
|
Self { store, ids, clock }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes device pairing.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`AppError::Invalid`] for an invalid name and [`AppError::Store`] for
|
||||||
|
/// persistence failures.
|
||||||
|
pub async fn execute(&self, input: PairDeviceInput) -> Result<PairDeviceOutput, AppError> {
|
||||||
|
let now = self.clock.now_millis().max(0) as u64;
|
||||||
|
let device = PairedDevice {
|
||||||
|
device_id: DeviceId::from_uuid(self.ids.new_uuid()),
|
||||||
|
name: DeviceName::new(input.name).map_err(|err| AppError::Invalid(err.to_string()))?,
|
||||||
|
paired_at_ms: now,
|
||||||
|
last_seen_at_ms: now,
|
||||||
|
session_token_hash: input.session_token_hash,
|
||||||
|
};
|
||||||
|
let mut devices = self.store.load_devices().await?;
|
||||||
|
devices.push(device.clone());
|
||||||
|
self.store.save_devices(&devices).await?;
|
||||||
|
Ok(PairDeviceOutput { device })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rate-limit key for pairing attempts.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct RateLimitKey {
|
||||||
|
/// Resolved origin identity, typically the peer IP after transport-layer proxy handling.
|
||||||
|
pub origin: String,
|
||||||
|
/// Logical route being limited.
|
||||||
|
pub route: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pairing-attempt limiter port.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait PairAttemptLimiter: Send + Sync {
|
||||||
|
/// Returns whether a new failed pairing attempt may be processed.
|
||||||
|
async fn check(&self, key: RateLimitKey) -> Result<PairAttemptDecision, StoreError>;
|
||||||
|
|
||||||
|
/// Records one failed pairing attempt for the key and the global bucket.
|
||||||
|
async fn record_failure(&self, key: RateLimitKey) -> Result<(), StoreError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a rate-limit check.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PairAttemptDecision {
|
||||||
|
/// Attempt can proceed.
|
||||||
|
Allowed,
|
||||||
|
/// Attempt is blocked by one of the buckets.
|
||||||
|
RateLimited,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input for [`AuthenticateSession`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct AuthenticateSessionInput {
|
||||||
|
/// Raw token bytes decoded from the session cookie.
|
||||||
|
pub token_bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticates a persisted device session.
|
||||||
|
pub struct AuthenticateSession {
|
||||||
|
store: Arc<dyn DeviceSessionStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthenticateSession {
|
||||||
|
/// Builds the use case from injected ports.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(store: Arc<dyn DeviceSessionStore>) -> Self {
|
||||||
|
Self { store }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the authenticated device, including its token hash, when valid.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`AppError::Store`] for persistence failures.
|
||||||
|
pub async fn execute(
|
||||||
|
&self,
|
||||||
|
input: AuthenticateSessionInput,
|
||||||
|
) -> Result<Option<AuthenticatedDevice>, AppError> {
|
||||||
|
Ok(self.store.authenticate_token(&input.token_bytes).await?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input for [`TouchDevice`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TouchDeviceInput {
|
||||||
|
/// Device to mark as seen.
|
||||||
|
pub device_id: DeviceId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output for [`TouchDevice`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TouchDeviceOutput {
|
||||||
|
/// Whether the store was rewritten.
|
||||||
|
pub updated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Device row exposed to presentation layers.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DeviceView {
|
||||||
|
/// Persistent device id.
|
||||||
|
pub device_id: DeviceId,
|
||||||
|
/// User-facing name.
|
||||||
|
pub name: String,
|
||||||
|
/// Pairing timestamp as epoch milliseconds.
|
||||||
|
pub paired_at_ms: u64,
|
||||||
|
/// Last successful authenticated access timestamp as epoch milliseconds.
|
||||||
|
pub last_seen_at_ms: u64,
|
||||||
|
/// Whether this row is the authenticated current device.
|
||||||
|
pub is_current_device: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input for [`ListDevices`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ListDevicesInput {
|
||||||
|
/// Current device id, when the driving adapter has an authenticated device.
|
||||||
|
pub current_device_id: Option<DeviceId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output for [`ListDevices`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ListDevicesOutput {
|
||||||
|
/// Paired devices.
|
||||||
|
pub devices: Vec<DeviceView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists paired devices without exposing transport-sensitive fields.
|
||||||
|
pub struct ListDevices {
|
||||||
|
store: Arc<dyn DeviceSessionStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListDevices {
|
||||||
|
/// Builds the use case from injected ports.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(store: Arc<dyn DeviceSessionStore>) -> Self {
|
||||||
|
Self { store }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the listing.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`AppError::Store`] for persistence failures.
|
||||||
|
pub async fn execute(&self, input: ListDevicesInput) -> Result<ListDevicesOutput, AppError> {
|
||||||
|
let devices = self
|
||||||
|
.store
|
||||||
|
.load_devices()
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|device| DeviceView {
|
||||||
|
device_id: device.device_id,
|
||||||
|
name: device.name.as_str().to_owned(),
|
||||||
|
paired_at_ms: device.paired_at_ms,
|
||||||
|
last_seen_at_ms: device.last_seen_at_ms,
|
||||||
|
is_current_device: Some(device.device_id) == input.current_device_id,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(ListDevicesOutput { devices })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input for [`RenameDevice`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RenameDeviceInput {
|
||||||
|
/// Device to rename.
|
||||||
|
pub device_id: DeviceId,
|
||||||
|
/// New user-facing name.
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renames a paired device.
|
||||||
|
pub struct RenameDevice {
|
||||||
|
store: Arc<dyn DeviceSessionStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenameDevice {
|
||||||
|
/// Builds the use case from injected ports.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(store: Arc<dyn DeviceSessionStore>) -> Self {
|
||||||
|
Self { store }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the rename.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`AppError::Invalid`] for an invalid name, [`AppError::NotFound`]
|
||||||
|
/// when the device does not exist, and [`AppError::Store`] for persistence failures.
|
||||||
|
pub async fn execute(&self, input: RenameDeviceInput) -> Result<(), AppError> {
|
||||||
|
let new_name =
|
||||||
|
DeviceName::new(input.name).map_err(|err| AppError::Invalid(err.to_string()))?;
|
||||||
|
let mut devices = self.store.load_devices().await?;
|
||||||
|
let Some(device) = devices
|
||||||
|
.iter_mut()
|
||||||
|
.find(|device| device.device_id == input.device_id)
|
||||||
|
else {
|
||||||
|
return Err(AppError::NotFound("device not found".to_owned()));
|
||||||
|
};
|
||||||
|
device.name = new_name;
|
||||||
|
self.store.save_devices(&devices).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Input for [`RevokeDevice`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RevokeDeviceInput {
|
||||||
|
/// Device to revoke.
|
||||||
|
pub device_id: DeviceId,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revokes one paired device and publishes a revocation event after persistence.
|
||||||
|
pub struct RevokeDevice {
|
||||||
|
store: Arc<dyn DeviceSessionStore>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RevokeDevice {
|
||||||
|
/// Builds the use case from injected ports.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(store: Arc<dyn DeviceSessionStore>, events: Arc<dyn EventBus>) -> Self {
|
||||||
|
Self { store, events }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the revocation.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`AppError::NotFound`] when the device does not exist and
|
||||||
|
/// [`AppError::Store`] for persistence failures.
|
||||||
|
pub async fn execute(&self, input: RevokeDeviceInput) -> Result<(), AppError> {
|
||||||
|
if !self.store.remove_device(input.device_id).await? {
|
||||||
|
return Err(AppError::NotFound("device not found".to_owned()));
|
||||||
|
}
|
||||||
|
self.events.publish(DomainEvent::DeviceRevoked {
|
||||||
|
device_id: input.device_id,
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revokes every paired device and publishes a global revocation event.
|
||||||
|
pub struct RevokeAllDevices {
|
||||||
|
store: Arc<dyn DeviceSessionStore>,
|
||||||
|
events: Arc<dyn EventBus>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RevokeAllDevices {
|
||||||
|
/// Builds the use case from injected ports.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(store: Arc<dyn DeviceSessionStore>, events: Arc<dyn EventBus>) -> Self {
|
||||||
|
Self { store, events }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes global revocation.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`AppError::Store`] for persistence failures.
|
||||||
|
pub async fn execute(&self) -> Result<(), AppError> {
|
||||||
|
self.store.save_devices(&[]).await?;
|
||||||
|
self.events.publish(DomainEvent::AllDevicesRevoked);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates `lastSeenAtMs`, throttled to avoid rewriting the JSON on every request.
|
||||||
|
pub struct TouchDevice {
|
||||||
|
store: Arc<dyn DeviceSessionStore>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TouchDevice {
|
||||||
|
/// Builds the use case from injected ports.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(store: Arc<dyn DeviceSessionStore>, clock: Arc<dyn Clock>) -> Self {
|
||||||
|
Self { store, clock }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Executes the touch.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns [`AppError::Store`] for persistence failures.
|
||||||
|
pub async fn execute(&self, input: TouchDeviceInput) -> Result<TouchDeviceOutput, AppError> {
|
||||||
|
let now = self.clock.now_millis().max(0) as u64;
|
||||||
|
let mut devices = self.store.load_devices().await?;
|
||||||
|
let Some(device) = devices
|
||||||
|
.iter_mut()
|
||||||
|
.find(|device| device.device_id == input.device_id)
|
||||||
|
else {
|
||||||
|
return Ok(TouchDeviceOutput { updated: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
if now.saturating_sub(device.last_seen_at_ms) < LAST_SEEN_TOUCH_THROTTLE_MS {
|
||||||
|
return Ok(TouchDeviceOutput { updated: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
device.last_seen_at_ms = now;
|
||||||
|
self.store.save_devices(&devices).await?;
|
||||||
|
Ok(TouchDeviceOutput { updated: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalizes user-entered pairing codes server-side.
|
||||||
|
#[must_use]
|
||||||
|
pub fn normalize_pairing_code(code: &str) -> String {
|
||||||
|
code.chars()
|
||||||
|
.filter(|ch| !ch.is_whitespace() && *ch != '-')
|
||||||
|
.flat_map(char::to_uppercase)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compares pairing codes after normalization without early-exit byte comparison.
|
||||||
|
#[must_use]
|
||||||
|
pub fn pairing_codes_equal(candidate: &str, expected: &str) -> bool {
|
||||||
|
let candidate = normalize_pairing_code(candidate);
|
||||||
|
let expected = normalize_pairing_code(expected);
|
||||||
|
let bytes_equal: bool = candidate.as_bytes().ct_eq(expected.as_bytes()).into();
|
||||||
|
bytes_equal && candidate.len() == expected.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::ports::StoreError;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FakeStore {
|
||||||
|
devices: Mutex<Vec<PairedDevice>>,
|
||||||
|
saves: Mutex<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl DeviceSessionStore for FakeStore {
|
||||||
|
async fn load_devices(&self) -> Result<Vec<PairedDevice>, StoreError> {
|
||||||
|
Ok(self.devices.lock().unwrap().clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_devices(&self, devices: &[PairedDevice]) -> Result<(), StoreError> {
|
||||||
|
*self.devices.lock().unwrap() = devices.to_vec();
|
||||||
|
*self.saves.lock().unwrap() += 1;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FixedClock(i64);
|
||||||
|
|
||||||
|
impl domain::ports::Clock for FixedClock {
|
||||||
|
fn now_millis(&self) -> i64 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FixedIds;
|
||||||
|
|
||||||
|
impl domain::ports::IdGenerator for FixedIds {
|
||||||
|
fn new_uuid(&self) -> Uuid {
|
||||||
|
Uuid::from_u128(7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pairing_code_normalization_removes_spaces_hyphens_and_uppercases() {
|
||||||
|
assert_eq!(normalize_pairing_code(" ab12-cd34 "), "AB12CD34");
|
||||||
|
assert!(pairing_codes_equal(" ab12-cd34 ", "AB12CD34"));
|
||||||
|
assert!(!pairing_codes_equal("AB12CD35", "AB12CD34"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pair_device_validates_name_and_persists_device() {
|
||||||
|
let store = Arc::new(FakeStore::default());
|
||||||
|
let use_case = PairDevice::new(
|
||||||
|
Arc::clone(&store) as Arc<dyn DeviceSessionStore>,
|
||||||
|
Arc::new(FixedIds),
|
||||||
|
Arc::new(FixedClock(1234)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = use_case
|
||||||
|
.execute(PairDeviceInput {
|
||||||
|
name: "Phone".to_owned(),
|
||||||
|
session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(output.device.name.as_str(), "Phone");
|
||||||
|
assert_eq!(store.load_devices().await.unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pair_device_rejects_empty_name() {
|
||||||
|
let use_case = PairDevice::new(
|
||||||
|
Arc::new(FakeStore::default()),
|
||||||
|
Arc::new(FixedIds),
|
||||||
|
Arc::new(FixedClock(1234)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let err = use_case
|
||||||
|
.execute(PairDeviceInput {
|
||||||
|
name: " ".to_owned(),
|
||||||
|
session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(err.code(), "INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn touch_device_is_throttled_to_five_minutes() {
|
||||||
|
let device = PairedDevice {
|
||||||
|
device_id: DeviceId::from_uuid(Uuid::from_u128(7)),
|
||||||
|
name: DeviceName::new("Phone").unwrap(),
|
||||||
|
paired_at_ms: 1000,
|
||||||
|
last_seen_at_ms: 1000,
|
||||||
|
session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]),
|
||||||
|
};
|
||||||
|
let store = Arc::new(FakeStore::default());
|
||||||
|
store.save_devices(&[device]).await.unwrap();
|
||||||
|
*store.saves.lock().unwrap() = 0;
|
||||||
|
let use_case = TouchDevice::new(
|
||||||
|
Arc::clone(&store) as Arc<dyn DeviceSessionStore>,
|
||||||
|
Arc::new(FixedClock(
|
||||||
|
(1000 + LAST_SEEN_TOUCH_THROTTLE_MS - 1) as i64,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = use_case
|
||||||
|
.execute(TouchDeviceInput {
|
||||||
|
device_id: DeviceId::from_uuid(Uuid::from_u128(7)),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!output.updated);
|
||||||
|
assert_eq!(*store.saves.lock().unwrap(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@
|
|||||||
pub mod agent;
|
pub mod agent;
|
||||||
pub mod background;
|
pub mod background;
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
|
pub mod device;
|
||||||
pub mod diag;
|
pub mod diag;
|
||||||
pub mod embedder;
|
pub mod embedder;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
@ -65,6 +66,13 @@ pub use conversation::{
|
|||||||
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn,
|
ConversationArchiveProvider, ReadConversationPage, ReadConversationPageInput, RecordTurn,
|
||||||
RotateConversationLog, RotateConversationLogInput, TurnPage, TurnSource, TurnView,
|
RotateConversationLog, RotateConversationLogInput, TurnPage, TurnSource, TurnView,
|
||||||
};
|
};
|
||||||
|
pub use device::{
|
||||||
|
normalize_pairing_code, pairing_codes_equal, AuthenticateSession, AuthenticateSessionInput,
|
||||||
|
DeviceView, ListDevices, ListDevicesInput, ListDevicesOutput, PairAttemptDecision,
|
||||||
|
PairAttemptLimiter, PairDevice, PairDeviceInput, PairDeviceOutput, RateLimitKey, RenameDevice,
|
||||||
|
RenameDeviceInput, RevokeAllDevices, RevokeDevice, RevokeDeviceInput, TouchDevice,
|
||||||
|
TouchDeviceInput, TouchDeviceOutput,
|
||||||
|
};
|
||||||
pub use embedder::{
|
pub use embedder::{
|
||||||
CheckEmbedderSuggestion, CheckEmbedderSuggestionInput, CheckEmbedderSuggestionOutput,
|
CheckEmbedderSuggestion, CheckEmbedderSuggestionInput, CheckEmbedderSuggestionOutput,
|
||||||
DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines, DismissChoice,
|
DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines, DismissChoice,
|
||||||
|
|||||||
@ -302,6 +302,14 @@ pub enum DomainEventDto {
|
|||||||
/// Version synced to.
|
/// Version synced to.
|
||||||
to: u64,
|
to: u64,
|
||||||
},
|
},
|
||||||
|
/// A paired device was revoked.
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
DeviceRevoked {
|
||||||
|
/// Device id.
|
||||||
|
device_id: String,
|
||||||
|
},
|
||||||
|
/// Every paired device was revoked.
|
||||||
|
AllDevicesRevoked,
|
||||||
/// A skill was assigned to (or unassigned from) an agent.
|
/// A skill was assigned to (or unassigned from) an agent.
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
SkillAssigned {
|
SkillAssigned {
|
||||||
@ -884,6 +892,10 @@ impl From<&DomainEvent> for DomainEventDto {
|
|||||||
agent_id: agent_id.to_string(),
|
agent_id: agent_id.to_string(),
|
||||||
to: to.get(),
|
to: to.get(),
|
||||||
},
|
},
|
||||||
|
DomainEvent::DeviceRevoked { device_id } => Self::DeviceRevoked {
|
||||||
|
device_id: device_id.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::AllDevicesRevoked => Self::AllDevicesRevoked,
|
||||||
DomainEvent::SkillAssigned {
|
DomainEvent::SkillAssigned {
|
||||||
agent_id,
|
agent_id,
|
||||||
skill_id,
|
skill_id,
|
||||||
|
|||||||
@ -13,43 +13,46 @@ use std::sync::{Arc, Mutex};
|
|||||||
|
|
||||||
use application::{
|
use application::{
|
||||||
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
|
AgentResumer, AgentWakeService, AppError, AssignIssueAgent, AssignSkillToAgent,
|
||||||
AssignTicketToSprint, AttachLiveAgent, BackgroundCommandArchive, CancelBackgroundTask,
|
AssignTicketToSprint, AttachLiveAgent, AuthenticateSession, BackgroundCommandArchive,
|
||||||
ChangeAgentProfile, CheckEmbedderSuggestion, CloneOpenCodeProfileFromSeed, CloseProject,
|
CancelBackgroundTask, ChangeAgentProfile, CheckEmbedderSuggestion,
|
||||||
CloseTab, CloseTerminal, CloseTicketAssistant, ConfigureProfiles, ContextGuardUseCases,
|
CloneOpenCodeProfileFromSeed, CloseProject, CloseTab, CloseTerminal, CloseTicketAssistant,
|
||||||
CreateAgentFromScratch, CreateAgentFromTemplate, CreateIssue, CreateLayout, CreateMemory,
|
ConfigureProfiles, ContextGuardUseCases, CreateAgentFromScratch, CreateAgentFromTemplate,
|
||||||
CreateProject, CreateSkill, CreateSprint, CreateTemplate, DeleteAgent, DeleteEmbedderProfile,
|
CreateIssue, CreateLayout, CreateMemory, CreateProject, CreateSkill, CreateSprint,
|
||||||
DeleteIssue, DeleteLayout, DeleteMemory, DeleteModelServer, DeleteProfile, DeleteSkill,
|
CreateTemplate, DeleteAgent, DeleteEmbedderProfile, DeleteIssue, DeleteLayout, DeleteMemory,
|
||||||
DeleteSprint, DeleteTemplate, DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles,
|
DeleteModelServer, DeleteProfile, DeleteSkill, DeleteSprint, DeleteTemplate,
|
||||||
DismissEmbedderSuggestion, EnsureLocalModelServer, FirstRunState, GetLiveStateLean, GetMemory,
|
DescribeEmbedderEngines, DetectAgentDrift, DetectProfiles, DismissEmbedderSuggestion,
|
||||||
GetProjectPermissions, GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph,
|
EnsureLocalModelServer, FirstRunState, GetLiveStateLean, GetMemory, GetProjectPermissions,
|
||||||
GitInit, GitLog, GitStage, GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase,
|
GetProjectWorkState, GitBranches, GitCheckout, GitCommit, GitGraph, GitInit, GitLog, GitStage,
|
||||||
InspectConversation, LaunchAgent, LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput,
|
GitStatus, GitUnstage, HarvestMemoryFromTurn, HealthUseCase, InspectConversation, LaunchAgent,
|
||||||
ListEmbedderProfiles, ListIssues, ListLayouts, ListMemories, ListModelServers, ListProfiles,
|
LaunchAgentInput, LinkIssues, ListAgents, ListAgentsInput, ListDevices, ListEmbedderProfiles,
|
||||||
ListProjects, ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry,
|
ListIssues, ListLayouts, ListMemories, ListModelServers, ListProfiles, ListProjects,
|
||||||
LiveSessions, LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout,
|
ListResumableAgents, ListSkills, ListSprints, ListTemplates, LiveAgentRegistry, LiveSessions,
|
||||||
McpRuntime, MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
LiveStateLeanProvider, LiveStateProvider, LiveStateReadProvider, LoadLayout, McpRuntime,
|
||||||
OpenTicketAssistant, OrchestratorService, PermissionProjectorRegistry, ProposeContext,
|
MoveTabToNewWindow, MutateLayout, OnnxModelView, OpenProject, OpenTerminal,
|
||||||
ReadAgentContext, ReadContext, ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory,
|
OpenTicketAssistant, OrchestratorService, PairAttemptLimiter, PairDevice,
|
||||||
ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts,
|
PermissionProjectorRegistry, ProposeContext, ReadAgentContext, ReadContext,
|
||||||
ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles,
|
ReadConversationPage, ReadIssue, ReadIssueCarnet, ReadMemory, ReadMemoryIndex,
|
||||||
|
ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReconcileLiveState,
|
||||||
|
ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameDevice,
|
||||||
RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions,
|
RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions,
|
||||||
ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RotateConversationLog,
|
ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RevokeAllDevices, RevokeDevice,
|
||||||
SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService, SetActiveLayout,
|
RotateConversationLog, SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService,
|
||||||
SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent,
|
SetActiveLayout, SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand,
|
||||||
StructuredRoutingMode, StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate,
|
StopLiveAgent, StructuredRoutingMode, StructuredSessions, SuggestedThisSession,
|
||||||
TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues,
|
SyncAgentWithTemplate, TerminalSessions, TouchDevice, UnassignSkillFromAgent,
|
||||||
UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState,
|
UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions,
|
||||||
UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate,
|
UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
|
||||||
WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
UpdateProjectPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory,
|
||||||
|
WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore,
|
AgentContextStore, AgentRuntime, AgentSession, AgentSessionFactory, AgentToolPolicyStore,
|
||||||
AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner,
|
AgentWakePort, AssistantContextProvider, BackgroundTaskPortError, BackgroundTaskRunner,
|
||||||
BackgroundTaskStore, Clock, Embedder, EmbedderEnvInspector, EmbedderProfileStore,
|
BackgroundTaskStore, Clock, DeviceSessionStore, Embedder, EmbedderEnvInspector,
|
||||||
EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator,
|
EmbedderProfileStore, EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator,
|
||||||
IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore,
|
IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner,
|
||||||
ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore,
|
ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore,
|
||||||
StructuredSessionEnvironmentPreparer, TemplateStore, ToolInvoker, WakeError, WakeReason,
|
StructuredSessionEnvironmentPreparer, TemplateStore, ToolInvoker, WakeError, WakeReason,
|
||||||
WindowStateStore,
|
WindowStateStore,
|
||||||
};
|
};
|
||||||
@ -69,14 +72,15 @@ use infrastructure::{
|
|||||||
embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink,
|
embedder_from_profile, AdaptiveMemoryRecall, BackgroundCompletionSink,
|
||||||
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
|
BackgroundTaskReadyToDeliver, ClaudePermissionProjector, ClaudeTranscriptInspector,
|
||||||
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe,
|
CliAgentRuntime, CodexPermissionProjector, CommandBackgroundRunner, EmbedderEnvProbe,
|
||||||
FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsEmbedderProfileStore,
|
FsAssistantContextStore, FsBackgroundTaskStore, FsConversationLog, FsDeviceSessionStore,
|
||||||
FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore,
|
FsEmbedderProfileStore, FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator,
|
||||||
FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore,
|
FsIssueStore, FsLiveStateStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher,
|
||||||
FsProjectStore, FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore,
|
FsPermissionStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSkillStore,
|
||||||
FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HfModelArtifactDownloader,
|
FsSprintStore, FsTemplateStore, FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer,
|
||||||
HttpOpenAiCompatibleProbe, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
|
HfModelArtifactDownloader, HttpOpenAiCompatibleProbe, IdeaiContextStore,
|
||||||
LlamaCppRuntime, LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer,
|
InMemoryConversationRegistry, InMemoryMailbox, InMemoryPairAttemptLimiter, LlamaCppRuntime,
|
||||||
MediatedInbox, NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard,
|
LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox,
|
||||||
|
NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard,
|
||||||
StructuredSessionFactory, SystemClock, SystemMillisClock, TicketAssistantEnvironmentPreparer,
|
StructuredSessionFactory, SystemClock, SystemMillisClock, TicketAssistantEnvironmentPreparer,
|
||||||
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
|
TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator,
|
||||||
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
|
||||||
@ -798,6 +802,24 @@ impl AgentResumer for AppAgentResumer {
|
|||||||
pub struct BackendCore {
|
pub struct BackendCore {
|
||||||
/// Trivial health use case validating the end-to-end wiring.
|
/// Trivial health use case validating the end-to-end wiring.
|
||||||
pub health: Arc<HealthUseCase>,
|
pub health: Arc<HealthUseCase>,
|
||||||
|
/// Pair a persistent device session.
|
||||||
|
pub pair_device: Arc<PairDevice>,
|
||||||
|
/// Limits failed pairing attempts.
|
||||||
|
pub pair_attempt_limiter: Arc<dyn PairAttemptLimiter>,
|
||||||
|
/// Authenticate a persistent device session.
|
||||||
|
pub authenticate_session: Arc<AuthenticateSession>,
|
||||||
|
/// Throttled update of the authenticated device last-seen timestamp.
|
||||||
|
pub touch_device: Arc<TouchDevice>,
|
||||||
|
/// List paired devices.
|
||||||
|
pub list_devices: Arc<ListDevices>,
|
||||||
|
/// Rename a paired device.
|
||||||
|
pub rename_device: Arc<RenameDevice>,
|
||||||
|
/// Revoke one paired device.
|
||||||
|
pub revoke_device: Arc<RevokeDevice>,
|
||||||
|
/// Revoke every paired device.
|
||||||
|
pub revoke_all_devices: Arc<RevokeAllDevices>,
|
||||||
|
/// Paired-device store port, exposed for legacy logout revocation.
|
||||||
|
pub device_session_store: Arc<dyn DeviceSessionStore>,
|
||||||
/// Create a project (init `.ideai/`, register it).
|
/// Create a project (init `.ideai/`, register it).
|
||||||
pub create_project: Arc<CreateProject>,
|
pub create_project: Arc<CreateProject>,
|
||||||
/// Open a project (load meta + manifest).
|
/// Open a project (load meta + manifest).
|
||||||
@ -1134,11 +1156,21 @@ impl BackendCore {
|
|||||||
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||||
app_data_dir.to_string_lossy().into_owned(),
|
app_data_dir.to_string_lossy().into_owned(),
|
||||||
));
|
));
|
||||||
|
let device_session_store = Arc::new(FsDeviceSessionStore::new(
|
||||||
|
Arc::clone(&fs) as Arc<dyn FileSystem>,
|
||||||
|
app_data_dir.to_string_lossy().into_owned(),
|
||||||
|
));
|
||||||
|
let pair_attempt_limiter = Arc::new(InMemoryPairAttemptLimiter::new(
|
||||||
|
Arc::clone(&clock) as Arc<dyn Clock>
|
||||||
|
));
|
||||||
|
|
||||||
// Port-typed handles for injection.
|
// Port-typed handles for injection.
|
||||||
let fs_port = Arc::clone(&fs) as Arc<dyn FileSystem>;
|
let fs_port = Arc::clone(&fs) as Arc<dyn FileSystem>;
|
||||||
let store_port = Arc::clone(&store) as Arc<dyn ProjectStore>;
|
let store_port = Arc::clone(&store) as Arc<dyn ProjectStore>;
|
||||||
let window_state_port = Arc::clone(&window_state_store) as Arc<dyn WindowStateStore>;
|
let window_state_port = Arc::clone(&window_state_store) as Arc<dyn WindowStateStore>;
|
||||||
|
let device_session_port = Arc::clone(&device_session_store) as Arc<dyn DeviceSessionStore>;
|
||||||
|
let pair_attempt_limiter_port =
|
||||||
|
Arc::clone(&pair_attempt_limiter) as Arc<dyn PairAttemptLimiter>;
|
||||||
let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
|
let events_port = Arc::clone(&event_bus) as Arc<dyn EventBus>;
|
||||||
|
|
||||||
// --- Use cases (ports injected as Arc<dyn Port>) ---
|
// --- Use cases (ports injected as Arc<dyn Port>) ---
|
||||||
@ -1147,6 +1179,27 @@ impl BackendCore {
|
|||||||
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
Arc::clone(&events_port),
|
Arc::clone(&events_port),
|
||||||
));
|
));
|
||||||
|
let pair_device = Arc::new(PairDevice::new(
|
||||||
|
Arc::clone(&device_session_port),
|
||||||
|
Arc::clone(&ids) as Arc<dyn IdGenerator>,
|
||||||
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
|
));
|
||||||
|
let authenticate_session =
|
||||||
|
Arc::new(AuthenticateSession::new(Arc::clone(&device_session_port)));
|
||||||
|
let touch_device = Arc::new(TouchDevice::new(
|
||||||
|
Arc::clone(&device_session_port),
|
||||||
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
|
));
|
||||||
|
let list_devices = Arc::new(ListDevices::new(Arc::clone(&device_session_port)));
|
||||||
|
let rename_device = Arc::new(RenameDevice::new(Arc::clone(&device_session_port)));
|
||||||
|
let revoke_device = Arc::new(RevokeDevice::new(
|
||||||
|
Arc::clone(&device_session_port),
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
let revoke_all_devices = Arc::new(RevokeAllDevices::new(
|
||||||
|
Arc::clone(&device_session_port),
|
||||||
|
Arc::clone(&events_port),
|
||||||
|
));
|
||||||
|
|
||||||
let create_project = Arc::new(CreateProject::new(
|
let create_project = Arc::new(CreateProject::new(
|
||||||
Arc::clone(&store_port),
|
Arc::clone(&store_port),
|
||||||
@ -2316,6 +2369,15 @@ impl BackendCore {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
health,
|
health,
|
||||||
|
pair_device,
|
||||||
|
pair_attempt_limiter: Arc::clone(&pair_attempt_limiter_port),
|
||||||
|
authenticate_session,
|
||||||
|
touch_device,
|
||||||
|
list_devices,
|
||||||
|
rename_device,
|
||||||
|
revoke_device,
|
||||||
|
revoke_all_devices,
|
||||||
|
device_session_store: Arc::clone(&device_session_port),
|
||||||
create_project,
|
create_project,
|
||||||
open_project,
|
open_project,
|
||||||
close_project,
|
close_project,
|
||||||
|
|||||||
@ -12,6 +12,9 @@ serde = { workspace = true }
|
|||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
|
hex = { workspace = true }
|
||||||
|
sha2 = { workspace = true }
|
||||||
|
subtle = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
|||||||
184
crates/domain/src/device.rs
Normal file
184
crates/domain/src/device.rs
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
//! Persistent paired-device access model.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
|
use thiserror::Error;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const SESSION_TOKEN_HASH_PREFIX: &str = "sha256:";
|
||||||
|
const SESSION_TOKEN_HASH_CONTEXT: &[u8] = b"idea-session-v1\0";
|
||||||
|
const SESSION_TOKEN_HASH_HEX_LEN: usize = 64;
|
||||||
|
|
||||||
|
/// Unique identifier for a paired device.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct DeviceId(Uuid);
|
||||||
|
|
||||||
|
impl DeviceId {
|
||||||
|
/// Builds a device id from an existing UUID.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn from_uuid(value: Uuid) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the wrapped UUID.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_uuid(self) -> Uuid {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for DeviceId {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
self.0.fmt(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Device display name supplied by the newly paired device.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct DeviceName(String);
|
||||||
|
|
||||||
|
impl DeviceName {
|
||||||
|
/// Validates and stores a device name.
|
||||||
|
///
|
||||||
|
/// Names are required and limited to 40 Unicode scalar values.
|
||||||
|
pub fn new(value: impl Into<String>) -> Result<Self, DeviceError> {
|
||||||
|
let value = value.into().trim().to_owned();
|
||||||
|
let len = value.chars().count();
|
||||||
|
if len == 0 {
|
||||||
|
return Err(DeviceError::InvalidName(
|
||||||
|
"device name is required".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if len > 40 {
|
||||||
|
return Err(DeviceError::InvalidName(
|
||||||
|
"device name must be 40 characters or fewer".to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Self(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the stored name.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash of a random 256-bit session token.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct SessionTokenHash(String);
|
||||||
|
|
||||||
|
impl SessionTokenHash {
|
||||||
|
/// Hashes raw token bytes as `SHA-256("idea-session-v1\0" || token_bytes)`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_token_bytes(token_bytes: &[u8]) -> Self {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(SESSION_TOKEN_HASH_CONTEXT);
|
||||||
|
hasher.update(token_bytes);
|
||||||
|
let digest = hasher.finalize();
|
||||||
|
Self(format!(
|
||||||
|
"{SESSION_TOKEN_HASH_PREFIX}{}",
|
||||||
|
hex::encode(digest)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates an already persisted hash string.
|
||||||
|
pub fn new(value: impl Into<String>) -> Result<Self, DeviceError> {
|
||||||
|
let value = value.into();
|
||||||
|
validate_hash_string(&value)?;
|
||||||
|
Ok(Self(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the persisted hash string.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constant-time verification of raw token bytes against this stored hash.
|
||||||
|
#[must_use]
|
||||||
|
pub fn verify_token_bytes(&self, token_bytes: &[u8]) -> bool {
|
||||||
|
let candidate = Self::from_token_bytes(token_bytes);
|
||||||
|
self.constant_time_eq(&candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constant-time equality for two valid persisted token hashes.
|
||||||
|
#[must_use]
|
||||||
|
pub fn constant_time_eq(&self, other: &Self) -> bool {
|
||||||
|
self.0.as_bytes().ct_eq(other.0.as_bytes()).into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A paired device persisted in the app-data security store.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PairedDevice {
|
||||||
|
/// Persistent device id.
|
||||||
|
pub device_id: DeviceId,
|
||||||
|
/// User-facing device name.
|
||||||
|
pub name: DeviceName,
|
||||||
|
/// Pairing timestamp as epoch milliseconds.
|
||||||
|
pub paired_at_ms: u64,
|
||||||
|
/// Last successful authenticated access timestamp as epoch milliseconds.
|
||||||
|
pub last_seen_at_ms: u64,
|
||||||
|
/// Hash of the bearer session token.
|
||||||
|
pub session_token_hash: SessionTokenHash,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Successful session authentication result.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct AuthenticatedDevice {
|
||||||
|
/// Authenticated device id.
|
||||||
|
pub device_id: DeviceId,
|
||||||
|
/// Matched token hash.
|
||||||
|
pub token_hash: SessionTokenHash,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Device-domain validation errors.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||||
|
pub enum DeviceError {
|
||||||
|
/// Invalid device name.
|
||||||
|
#[error("invalid device name: {0}")]
|
||||||
|
InvalidName(String),
|
||||||
|
/// Invalid persisted token hash.
|
||||||
|
#[error("invalid session token hash")]
|
||||||
|
InvalidSessionTokenHash,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_hash_string(value: &str) -> Result<(), DeviceError> {
|
||||||
|
let Some(hex) = value.strip_prefix(SESSION_TOKEN_HASH_PREFIX) else {
|
||||||
|
return Err(DeviceError::InvalidSessionTokenHash);
|
||||||
|
};
|
||||||
|
if hex.len() != SESSION_TOKEN_HASH_HEX_LEN || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||||
|
return Err(DeviceError::InvalidSessionTokenHash);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_token_hash_is_prefixed_sha256_and_verifies_constant_time() {
|
||||||
|
let token = [7_u8; 32];
|
||||||
|
let hash = SessionTokenHash::from_token_bytes(&token);
|
||||||
|
|
||||||
|
assert!(hash.as_str().starts_with("sha256:"));
|
||||||
|
assert_eq!(hash.as_str().len(), "sha256:".len() + 64);
|
||||||
|
assert!(hash.verify_token_bytes(&token));
|
||||||
|
assert!(!hash.verify_token_bytes(&[8_u8; 32]));
|
||||||
|
assert!(hash.constant_time_eq(&SessionTokenHash::new(hash.as_str()).unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn device_name_is_required_and_limited() {
|
||||||
|
assert!(DeviceName::new("phone").is_ok());
|
||||||
|
assert!(DeviceName::new(" ").is_err());
|
||||||
|
assert!(DeviceName::new("a".repeat(41)).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
//! presentation layer (ARCHITECTURE §3.2).
|
//! presentation layer (ARCHITECTURE §3.2).
|
||||||
|
|
||||||
use crate::conversation::ConversationParty;
|
use crate::conversation::ConversationParty;
|
||||||
|
use crate::device::DeviceId;
|
||||||
use crate::ids::{
|
use crate::ids::{
|
||||||
AgentId, IssueId, LocalModelServerId, ProfileId, ProjectId, SessionId, SkillId, SprintId,
|
AgentId, IssueId, LocalModelServerId, ProfileId, ProjectId, SessionId, SkillId, SprintId,
|
||||||
TaskId, TemplateId,
|
TaskId, TemplateId,
|
||||||
@ -268,6 +269,13 @@ pub enum DomainEvent {
|
|||||||
/// Version it was brought up to.
|
/// Version it was brought up to.
|
||||||
to: TemplateVersion,
|
to: TemplateVersion,
|
||||||
},
|
},
|
||||||
|
/// A paired device was revoked and any live transport for it must be closed.
|
||||||
|
DeviceRevoked {
|
||||||
|
/// Revoked device.
|
||||||
|
device_id: DeviceId,
|
||||||
|
},
|
||||||
|
/// All paired devices were revoked and every live paired transport must close.
|
||||||
|
AllDevicesRevoked,
|
||||||
/// A skill was assigned to (or unassigned from) an agent.
|
/// A skill was assigned to (or unassigned from) an agent.
|
||||||
SkillAssigned {
|
SkillAssigned {
|
||||||
/// The agent whose skill set changed.
|
/// The agent whose skill set changed.
|
||||||
|
|||||||
@ -35,6 +35,7 @@ pub mod agent_tool_policy;
|
|||||||
pub mod background_task;
|
pub mod background_task;
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
pub mod conversation_log;
|
pub mod conversation_log;
|
||||||
|
pub mod device;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod fileguard;
|
pub mod fileguard;
|
||||||
@ -140,6 +141,10 @@ pub use conversation_log::{
|
|||||||
ROTATE_AFTER_BYTES, ROTATE_AFTER_TURNS,
|
ROTATE_AFTER_BYTES, ROTATE_AFTER_TURNS,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub use device::{
|
||||||
|
AuthenticatedDevice, DeviceError, DeviceId, DeviceName, PairedDevice, SessionTokenHash,
|
||||||
|
};
|
||||||
|
|
||||||
pub use fileguard::{
|
pub use fileguard::{
|
||||||
is_orchestrator, may_write_directly, FileGuard, GuardError, GuardedResource,
|
is_orchestrator, may_write_directly, FileGuard, GuardError, GuardedResource,
|
||||||
OrchestratorDesignation, ReadLease, WriteLease,
|
OrchestratorDesignation, ReadLease, WriteLease,
|
||||||
|
|||||||
@ -34,6 +34,7 @@ use crate::agent_tool_policy::AgentToolPolicy;
|
|||||||
use crate::background_task::{
|
use crate::background_task::{
|
||||||
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
|
BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy,
|
||||||
};
|
};
|
||||||
|
use crate::device::{AuthenticatedDevice, DeviceId, PairedDevice};
|
||||||
use crate::events::DomainEvent;
|
use crate::events::DomainEvent;
|
||||||
use crate::ids::{
|
use crate::ids::{
|
||||||
AgentId, LocalModelServerId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId,
|
AgentId, LocalModelServerId, NodeId, ProjectId, ScheduleId, SessionId, SprintId, TaskId,
|
||||||
@ -1688,6 +1689,45 @@ pub trait EmbedderPromptStore: Send + Sync {
|
|||||||
) -> Result<(), StoreError>;
|
) -> Result<(), StoreError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persists paired devices and their session-token hashes.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait DeviceSessionStore: Send + Sync {
|
||||||
|
/// Loads all paired devices. A missing store is represented as an empty list.
|
||||||
|
async fn load_devices(&self) -> Result<Vec<PairedDevice>, StoreError>;
|
||||||
|
|
||||||
|
/// Replaces the persisted device list atomically enough for the filesystem
|
||||||
|
/// adapter's single-process use case.
|
||||||
|
async fn save_devices(&self, devices: &[PairedDevice]) -> Result<(), StoreError>;
|
||||||
|
|
||||||
|
/// Removes one device by id.
|
||||||
|
async fn remove_device(&self, device_id: DeviceId) -> Result<bool, StoreError> {
|
||||||
|
let mut devices = self.load_devices().await?;
|
||||||
|
let before = devices.len();
|
||||||
|
devices.retain(|device| device.device_id != device_id);
|
||||||
|
if devices.len() != before {
|
||||||
|
self.save_devices(&devices).await?;
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticates a session token against persisted token hashes.
|
||||||
|
async fn authenticate_token(
|
||||||
|
&self,
|
||||||
|
token_bytes: &[u8],
|
||||||
|
) -> Result<Option<AuthenticatedDevice>, StoreError> {
|
||||||
|
for device in self.load_devices().await? {
|
||||||
|
if device.session_token_hash.verify_token_bytes(token_bytes) {
|
||||||
|
return Ok(Some(AuthenticatedDevice {
|
||||||
|
device_id: device.device_id,
|
||||||
|
token_hash: device.session_token_hash,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Reads/writes agent `.md` contexts and the project manifest, within a project.
|
/// Reads/writes agent `.md` contexts and the project manifest, within a project.
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait AgentContextStore: Send + Sync {
|
pub trait AgentContextStore: Send + Sync {
|
||||||
|
|||||||
@ -28,6 +28,7 @@ pub mod issues;
|
|||||||
pub mod mailbox;
|
pub mod mailbox;
|
||||||
pub mod model_server;
|
pub mod model_server;
|
||||||
pub mod orchestrator;
|
pub mod orchestrator;
|
||||||
|
pub mod pair_attempt_limiter;
|
||||||
pub mod permission;
|
pub mod permission;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
@ -77,6 +78,7 @@ pub use orchestrator::{
|
|||||||
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
|
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,
|
||||||
REQUESTS_SUBDIR,
|
REQUESTS_SUBDIR,
|
||||||
};
|
};
|
||||||
|
pub use pair_attempt_limiter::InMemoryPairAttemptLimiter;
|
||||||
pub use permission::{ClaudePermissionProjector, CodexPermissionProjector};
|
pub use permission::{ClaudePermissionProjector, CodexPermissionProjector};
|
||||||
pub use process::LocalProcessSpawner;
|
pub use process::LocalProcessSpawner;
|
||||||
pub use pty::PortablePtyAdapter;
|
pub use pty::PortablePtyAdapter;
|
||||||
@ -96,9 +98,9 @@ pub use store::{detect_ollama, HttpEmbedder, DEFAULT_LOCAL_EMBED_ENDPOINT};
|
|||||||
pub use store::{
|
pub use store::{
|
||||||
embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector,
|
embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector,
|
||||||
AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore,
|
AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore,
|
||||||
FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore, FsMemoryStore,
|
FsDeviceSessionStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore,
|
||||||
FsPermissionStore, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore,
|
FsMemoryStore, FsPermissionStore, FsProfileStore, FsProjectStore, FsSkillStore,
|
||||||
FsWindowStateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo,
|
FsTemplateStore, FsWindowStateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall,
|
||||||
StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
OnnxModelInfo, StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
|
||||||
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED,
|
||||||
};
|
};
|
||||||
|
|||||||
153
crates/infrastructure/src/pair_attempt_limiter.rs
Normal file
153
crates/infrastructure/src/pair_attempt_limiter.rs
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
//! In-memory pairing-attempt limiter.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use application::{PairAttemptDecision, PairAttemptLimiter, RateLimitKey};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::ports::{Clock, StoreError};
|
||||||
|
|
||||||
|
const WINDOW_MS: i64 = 60_000;
|
||||||
|
const PER_ORIGIN_LIMIT: usize = 5;
|
||||||
|
const GLOBAL_LIMIT: usize = 30;
|
||||||
|
|
||||||
|
/// Process-local limiter for failed pairing attempts.
|
||||||
|
pub struct InMemoryPairAttemptLimiter {
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
inner: Mutex<LimiterState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct LimiterState {
|
||||||
|
by_origin: HashMap<String, Vec<i64>>,
|
||||||
|
global: Vec<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InMemoryPairAttemptLimiter {
|
||||||
|
/// Creates a limiter using the supplied clock.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(clock: Arc<dyn Clock>) -> Self {
|
||||||
|
Self {
|
||||||
|
clock,
|
||||||
|
inner: Mutex::new(LimiterState::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PairAttemptLimiter for InMemoryPairAttemptLimiter {
|
||||||
|
async fn check(&self, key: RateLimitKey) -> Result<PairAttemptDecision, StoreError> {
|
||||||
|
let now = self.clock.now_millis().max(0);
|
||||||
|
let cutoff = now.saturating_sub(WINDOW_MS);
|
||||||
|
let mut inner = self.inner.lock().expect("pair limiter mutex poisoned");
|
||||||
|
prune(&mut inner.global, cutoff);
|
||||||
|
let origin = origin_key(&key);
|
||||||
|
let origin_bucket = inner.by_origin.entry(origin).or_default();
|
||||||
|
prune(origin_bucket, cutoff);
|
||||||
|
if origin_bucket.len() >= PER_ORIGIN_LIMIT || inner.global.len() >= GLOBAL_LIMIT {
|
||||||
|
return Ok(PairAttemptDecision::RateLimited);
|
||||||
|
}
|
||||||
|
Ok(PairAttemptDecision::Allowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_failure(&self, key: RateLimitKey) -> Result<(), StoreError> {
|
||||||
|
let now = self.clock.now_millis().max(0);
|
||||||
|
let cutoff = now.saturating_sub(WINDOW_MS);
|
||||||
|
let mut inner = self.inner.lock().expect("pair limiter mutex poisoned");
|
||||||
|
prune(&mut inner.global, cutoff);
|
||||||
|
inner.global.push(now);
|
||||||
|
let origin = origin_key(&key);
|
||||||
|
let origin_bucket = inner.by_origin.entry(origin).or_default();
|
||||||
|
prune(origin_bucket, cutoff);
|
||||||
|
origin_bucket.push(now);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn origin_key(key: &RateLimitKey) -> String {
|
||||||
|
format!("{}:{}", key.route, key.origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prune(bucket: &mut Vec<i64>, cutoff: i64) {
|
||||||
|
bucket.retain(|timestamp| *timestamp > cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::atomic::{AtomicI64, Ordering};
|
||||||
|
|
||||||
|
struct FakeClock(AtomicI64);
|
||||||
|
|
||||||
|
impl FakeClock {
|
||||||
|
fn new(now: i64) -> Self {
|
||||||
|
Self(AtomicI64::new(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set(&self, now: i64) {
|
||||||
|
self.0.store(now, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clock for FakeClock {
|
||||||
|
fn now_millis(&self) -> i64 {
|
||||||
|
self.0.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(origin: &str) -> RateLimitKey {
|
||||||
|
RateLimitKey {
|
||||||
|
origin: origin.to_owned(),
|
||||||
|
route: "/api/pair".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn limits_five_failures_per_origin_per_minute_with_injected_clock() {
|
||||||
|
let clock = Arc::new(FakeClock::new(1_000));
|
||||||
|
let limiter = InMemoryPairAttemptLimiter::new(Arc::clone(&clock) as Arc<dyn Clock>);
|
||||||
|
|
||||||
|
for _ in 0..PER_ORIGIN_LIMIT {
|
||||||
|
assert_eq!(
|
||||||
|
limiter.check(key("1.2.3.4")).await.unwrap(),
|
||||||
|
PairAttemptDecision::Allowed
|
||||||
|
);
|
||||||
|
limiter.record_failure(key("1.2.3.4")).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
limiter.check(key("1.2.3.4")).await.unwrap(),
|
||||||
|
PairAttemptDecision::RateLimited
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.check(key("1.2.3.5")).await.unwrap(),
|
||||||
|
PairAttemptDecision::Allowed
|
||||||
|
);
|
||||||
|
|
||||||
|
clock.set(61_001);
|
||||||
|
assert_eq!(
|
||||||
|
limiter.check(key("1.2.3.4")).await.unwrap(),
|
||||||
|
PairAttemptDecision::Allowed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn limits_thirty_failures_globally_per_minute() {
|
||||||
|
let clock = Arc::new(FakeClock::new(1_000));
|
||||||
|
let limiter = InMemoryPairAttemptLimiter::new(clock as Arc<dyn Clock>);
|
||||||
|
|
||||||
|
for n in 0..GLOBAL_LIMIT {
|
||||||
|
let key = key(&format!("10.0.0.{n}"));
|
||||||
|
assert_eq!(
|
||||||
|
limiter.check(key.clone()).await.unwrap(),
|
||||||
|
PairAttemptDecision::Allowed
|
||||||
|
);
|
||||||
|
limiter.record_failure(key).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
limiter.check(key("10.0.0.99")).await.unwrap(),
|
||||||
|
PairAttemptDecision::RateLimited
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
176
crates/infrastructure/src/store/device_session.rs
Normal file
176
crates/infrastructure/src/store/device_session.rs
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
//! Filesystem-backed persistent paired-device store.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use domain::ports::{DeviceSessionStore, FileSystem, FsError, RemotePath, StoreError};
|
||||||
|
use domain::PairedDevice;
|
||||||
|
|
||||||
|
const DEVICES_DOC_VERSION: u8 = 1;
|
||||||
|
const SECURITY_DIR: &str = "security";
|
||||||
|
const DEVICES_FILE: &str = "devices.json";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
struct DevicesDoc {
|
||||||
|
version: u8,
|
||||||
|
devices: Vec<PairedDevice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON-file implementation for `{app_data_dir}/security/devices.json`.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FsDeviceSessionStore {
|
||||||
|
fs: Arc<dyn FileSystem>,
|
||||||
|
app_data_dir: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FsDeviceSessionStore {
|
||||||
|
/// Builds the store from an injected filesystem port and app-data directory.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
fs,
|
||||||
|
app_data_dir: app_data_dir.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn security_dir(&self) -> RemotePath {
|
||||||
|
RemotePath::new(format!(
|
||||||
|
"{}/{}",
|
||||||
|
self.app_data_dir.trim_end_matches(['/', '\\']),
|
||||||
|
SECURITY_DIR
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(&self) -> RemotePath {
|
||||||
|
RemotePath::new(format!("{}/{}", self.security_dir().as_str(), DEVICES_FILE))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl DeviceSessionStore for FsDeviceSessionStore {
|
||||||
|
async fn load_devices(&self) -> Result<Vec<PairedDevice>, StoreError> {
|
||||||
|
match self.fs.read(&self.path()).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let doc: DevicesDoc = serde_json::from_slice(&bytes)
|
||||||
|
.map_err(|err| StoreError::Serialization(err.to_string()))?;
|
||||||
|
if doc.version != DEVICES_DOC_VERSION {
|
||||||
|
return Err(StoreError::Serialization(format!(
|
||||||
|
"unsupported devices.json version {}",
|
||||||
|
doc.version
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(doc.devices)
|
||||||
|
}
|
||||||
|
Err(FsError::NotFound(_)) => Ok(Vec::new()),
|
||||||
|
Err(err) => Err(StoreError::Io(err.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_devices(&self, devices: &[PairedDevice]) -> Result<(), StoreError> {
|
||||||
|
self.fs
|
||||||
|
.create_dir_all(&self.security_dir())
|
||||||
|
.await
|
||||||
|
.map_err(|err| StoreError::Io(err.to_string()))?;
|
||||||
|
let doc = DevicesDoc {
|
||||||
|
version: DEVICES_DOC_VERSION,
|
||||||
|
devices: devices.to_vec(),
|
||||||
|
};
|
||||||
|
let mut bytes = serde_json::to_vec_pretty(&doc)
|
||||||
|
.map_err(|err| StoreError::Serialization(err.to_string()))?;
|
||||||
|
bytes.push(b'\n');
|
||||||
|
self.fs
|
||||||
|
.write(&self.path(), &bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|err| StoreError::Io(err.to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use self::infrastructure_test_support::TempFs;
|
||||||
|
use super::*;
|
||||||
|
use domain::{DeviceId, DeviceName, SessionTokenHash};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
mod infrastructure_test_support {
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::LocalFileSystem;
|
||||||
|
use domain::ports::FileSystem;
|
||||||
|
|
||||||
|
pub struct TempFs {
|
||||||
|
pub root: PathBuf,
|
||||||
|
pub fs: Arc<dyn FileSystem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TempFs {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let root = std::env::temp_dir()
|
||||||
|
.join(format!("idea-devices-store-{}", uuid::Uuid::new_v4()));
|
||||||
|
std::fs::create_dir_all(&root).unwrap();
|
||||||
|
Self {
|
||||||
|
root,
|
||||||
|
fs: Arc::new(LocalFileSystem::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path(&self, rel: &str) -> PathBuf {
|
||||||
|
self.root.join(Path::new(rel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempFs {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn device() -> PairedDevice {
|
||||||
|
PairedDevice {
|
||||||
|
device_id: DeviceId::from_uuid(Uuid::from_u128(1)),
|
||||||
|
name: DeviceName::new("Phone").unwrap(),
|
||||||
|
paired_at_ms: 1000,
|
||||||
|
last_seen_at_ms: 1000,
|
||||||
|
session_token_hash: SessionTokenHash::from_token_bytes(&[1; 32]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_file_loads_empty_device_list() {
|
||||||
|
let temp = TempFs::new();
|
||||||
|
let store = FsDeviceSessionStore::new(Arc::clone(&temp.fs), temp.root.to_string_lossy());
|
||||||
|
|
||||||
|
assert_eq!(store.load_devices().await.unwrap(), Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn devices_round_trip_in_v1_document() {
|
||||||
|
let temp = TempFs::new();
|
||||||
|
let store = FsDeviceSessionStore::new(Arc::clone(&temp.fs), temp.root.to_string_lossy());
|
||||||
|
|
||||||
|
store.save_devices(&[device()]).await.unwrap();
|
||||||
|
|
||||||
|
let loaded = store.load_devices().await.unwrap();
|
||||||
|
assert_eq!(loaded, vec![device()]);
|
||||||
|
let raw = std::fs::read_to_string(temp.path("security/devices.json")).unwrap();
|
||||||
|
assert!(raw.contains("\"version\": 1"));
|
||||||
|
assert!(raw.contains("\"deviceId\""));
|
||||||
|
assert!(raw.contains("\"sessionTokenHash\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn corrupted_json_is_a_serialization_error() {
|
||||||
|
let temp = TempFs::new();
|
||||||
|
std::fs::create_dir_all(temp.path("security")).unwrap();
|
||||||
|
std::fs::write(temp.path("security/devices.json"), b"{not json").unwrap();
|
||||||
|
let store = FsDeviceSessionStore::new(Arc::clone(&temp.fs), temp.root.to_string_lossy());
|
||||||
|
|
||||||
|
let err = store.load_devices().await.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(err, StoreError::Serialization(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
mod background_task;
|
mod background_task;
|
||||||
mod context;
|
mod context;
|
||||||
|
mod device_session;
|
||||||
mod embedder;
|
mod embedder;
|
||||||
mod live_state;
|
mod live_state;
|
||||||
mod memory;
|
mod memory;
|
||||||
@ -19,6 +20,7 @@ mod window_state;
|
|||||||
|
|
||||||
pub use background_task::{BackgroundTaskReconcileReport, FsBackgroundTaskStore};
|
pub use background_task::{BackgroundTaskReconcileReport, FsBackgroundTaskStore};
|
||||||
pub use context::IdeaiContextStore;
|
pub use context::IdeaiContextStore;
|
||||||
|
pub use device_session::FsDeviceSessionStore;
|
||||||
#[cfg(feature = "vector-onnx")]
|
#[cfg(feature = "vector-onnx")]
|
||||||
pub use embedder::OnnxEmbedder;
|
pub use embedder::OnnxEmbedder;
|
||||||
#[cfg(feature = "vector-http")]
|
#[cfg(feature = "vector-http")]
|
||||||
|
|||||||
@ -25,8 +25,12 @@ uuid = { workspace = true }
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
bytes = "1.11"
|
bytes = "1.11"
|
||||||
cookie = "0.18"
|
cookie = "0.18"
|
||||||
|
getrandom = { workspace = true }
|
||||||
|
hex = { workspace = true }
|
||||||
http = "1.4"
|
http = "1.4"
|
||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
|
sha2 = { workspace = true }
|
||||||
|
subtle = { workspace = true }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
vector-http = ["backend/vector-http"]
|
vector-http = ["backend/vector-http"]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user