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:
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 context;
|
||||
mod device_session;
|
||||
mod embedder;
|
||||
mod live_state;
|
||||
mod memory;
|
||||
@ -19,6 +20,7 @@ mod window_state;
|
||||
|
||||
pub use background_task::{BackgroundTaskReconcileReport, FsBackgroundTaskStore};
|
||||
pub use context::IdeaiContextStore;
|
||||
pub use device_session::FsDeviceSessionStore;
|
||||
#[cfg(feature = "vector-onnx")]
|
||||
pub use embedder::OnnxEmbedder;
|
||||
#[cfg(feature = "vector-http")]
|
||||
|
||||
Reference in New Issue
Block a user