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:
@ -16,12 +16,12 @@ use application::{
|
||||
DeleteSkillInput, DeleteTemplateInput, DetectAgentDriftInput, GetMemoryInput,
|
||||
GetProjectWorkStateInput, GitBranchesInput, GitCheckoutInput, GitCommitInput, GitGraphInput,
|
||||
GitInitInput, GitLogInput, GitStagePathInput, GitStatusInput, InspectConversationInput,
|
||||
LaunchAgentInput, ListAgentsInput, ListLayoutsInput, ListMemoriesInput,
|
||||
LaunchAgentInput, ListAgentsInput, ListDevicesInput, ListLayoutsInput, ListMemoriesInput,
|
||||
ListResumableAgentsInput, ListSkillsInput, LiveSessions, LoadLayoutInput, McpRuntime,
|
||||
MutateLayoutInput, OpenProjectInput, ReadAgentContextInput, ReadConversationPageInput,
|
||||
ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, ReconcileLayoutsInput,
|
||||
ReconcileLiveStateInput, RenameLayoutInput, ResolveAgentPermissionsInput,
|
||||
ResolveMemoryLinksInput, RotateConversationLogInput, SetActiveLayoutInput,
|
||||
ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, ResolveAgentPermissionsInput,
|
||||
ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput,
|
||||
SnapshotRunningAgentsInput, StopLiveAgentInput, SyncAgentWithTemplateInput,
|
||||
UnassignSkillFromAgentInput, UpdateAgentContextInput, UpdateAgentPermissionsInput,
|
||||
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectPermissionsInput, UpdateSkillInput,
|
||||
@ -66,7 +66,8 @@ use crate::embedded_server::{
|
||||
};
|
||||
use crate::pty::{PtyBridge, PtyChunk};
|
||||
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
|
||||
/// (frontend gateway → invoke → command → use case → ports → event relay).
|
||||
@ -149,6 +150,157 @@ pub async fn embedded_server_stop(
|
||||
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.
|
||||
///
|
||||
/// # Errors
|
||||
|
||||
@ -10,7 +10,7 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use backend::BackendCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use web_server::{EmbeddedServerHandle, ServerConfig, TrustedProxy};
|
||||
use web_server::{EmbeddedServerHandle, PairingCodeDto, ServerConfig, TrustedProxy};
|
||||
|
||||
use crate::dto::ErrorDto;
|
||||
|
||||
@ -93,8 +93,6 @@ pub struct EmbeddedServerStatusDto {
|
||||
pub public_url: Option<String>,
|
||||
/// Reverse-proxy upstream URL derived from settings.
|
||||
pub upstream_url: Option<String>,
|
||||
/// Runtime pairing code, never persisted.
|
||||
pub pairing_code: Option<String>,
|
||||
/// Last failure, when state is `failed`.
|
||||
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.
|
||||
///
|
||||
/// # Errors
|
||||
@ -331,10 +344,6 @@ fn status_from_inner(inner: &EmbeddedServerInner) -> EmbeddedServerStatusDto {
|
||||
local_url: inner.handle.as_ref().map(|handle| handle.url().to_owned()),
|
||||
public_url: inner.public_url.clone(),
|
||||
upstream_url: inner.upstream_url.clone(),
|
||||
pairing_code: inner
|
||||
.handle
|
||||
.as_ref()
|
||||
.map(|handle| handle.pairing_code().to_owned()),
|
||||
error,
|
||||
}
|
||||
}
|
||||
@ -436,6 +445,7 @@ fn server_config_from_settings(
|
||||
trusted_proxies,
|
||||
app_data_dir,
|
||||
web_root,
|
||||
new_code: false,
|
||||
};
|
||||
config.validate().map_err(invalid_error)?;
|
||||
Ok(config)
|
||||
@ -771,7 +781,9 @@ mod tests {
|
||||
.as_deref()
|
||||
.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_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();
|
||||
|
||||
@ -780,7 +792,6 @@ mod tests {
|
||||
EmbeddedServerStatusStateDto::Stopped
|
||||
));
|
||||
assert!(stopped.local_url.is_none());
|
||||
assert!(stopped.pairing_code.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -804,6 +815,5 @@ mod tests {
|
||||
|
||||
assert_eq!(err.code, "INVALID");
|
||||
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_start,
|
||||
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!())
|
||||
.expect("error while running IdeA Tauri application");
|
||||
|
||||
Reference in New Issue
Block a user