diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 187c93a4..c6814952 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -33,6 +33,7 @@ mod approvals; mod build_logs; mod journal; mod lifecycle_ops; +mod matrix_accounts; pub(crate) mod permissions; mod questions; mod reminders; @@ -100,6 +101,10 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/api/journal-host", get(journal::get_journal_host)) .route("/api/approval-diff/{id}", get(approvals::get_approval_diff)) .route("/api/state-file", get(state_files::get_state_file)) + .route( + "/api/matrix-accounts", + get(matrix_accounts::get_matrix_accounts), + ) .route("/api/reminders", get(reminders::api_reminders)) .route("/api/operator-inbox", get(api_operator_inbox)) .route("/api/stats-hive", get(api_stats_hive)) diff --git a/hive-c0re/src/dashboard/matrix_accounts.rs b/hive-c0re/src/dashboard/matrix_accounts.rs new file mode 100644 index 00000000..8d2d01bd --- /dev/null +++ b/hive-c0re/src/dashboard/matrix_accounts.rs @@ -0,0 +1,128 @@ +//! Matrix-account listing for the dashboard (#1698 backend, BE-1). +//! +//! `GET /api/matrix-accounts?agent=` returns the matrix accounts an +//! agent currently has a token provisioned for. v1 derives the list cheaply +//! from the agent's state dir — every `matrix-token*` file is a provisioned +//! account (`matrix-token` = the hive-internal `main` account; +//! `matrix-token-` = an extra account, matching the daemon's +//! path-watcher glob). It does not read the nix config, so it lists what is +//! *provisioned*, not the full configured set: a config-declared account +//! without a token yet appears once it is provisioned via the login form. +//! Homeserver + live up/down status arrive in v2 from the daemon's account +//! registry; here `homeserver` is always `null`. + +use axum::extract::Query; +use axum::response::{IntoResponse, Response}; +use serde::{Deserialize, Serialize}; + +use super::error_response; +use crate::coordinator::Coordinator; + +#[derive(Deserialize)] +pub(super) struct MatrixAccountsQuery { + agent: String, +} + +#[derive(Serialize)] +struct MatrixAccount { + name: String, + /// Always `None` in v1 (derived from the token filename, which carries no + /// homeserver); populated in v2 from the daemon's account registry. + homeserver: Option, + token_present: bool, +} + +#[derive(Serialize)] +struct MatrixAccountsResponse { + accounts: Vec, +} + +/// Map a state-dir filename to the matrix account name it provisions, or +/// `None` if it is not a token file. `matrix-token` → the hive-internal +/// `main` account; `matrix-token-` → the extra account ``. +fn account_name_from_filename(fname: &str) -> Option { + if fname == "matrix-token" { + return Some("main".to_owned()); + } + let suffix = fname.strip_prefix("matrix-token-")?; + if suffix.is_empty() { + return None; + } + Some(suffix.to_owned()) +} + +pub(super) async fn get_matrix_accounts(Query(q): Query) -> Response { + let agent = q.agent.trim(); + // Agent names are simple identifiers; reject anything else so a crafted + // `agent` can't escape the per-agent state root via path components. + if agent.is_empty() + || !agent + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return error_response(&format!("matrix-accounts: invalid agent name {agent:?}")); + } + + let dir = Coordinator::agent_notes_dir(agent); + let mut accounts = Vec::new(); + match std::fs::read_dir(&dir) { + Ok(entries) => { + for entry in entries.flatten() { + if !entry.file_type().is_ok_and(|ft| ft.is_file()) { + continue; + } + let fname = entry.file_name(); + let Some(fname) = fname.to_str() else { + continue; + }; + if let Some(name) = account_name_from_filename(fname) { + accounts.push(MatrixAccount { + name, + homeserver: None, + token_present: true, + }); + } + } + } + // No state dir / no tokens yet is a normal empty result, not an error. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + return error_response(&format!("matrix-accounts: read {}: {e}", dir.display())); + } + } + accounts.sort_by(|a, b| a.name.cmp(&b.name)); + axum::Json(MatrixAccountsResponse { accounts }).into_response() +} + +#[cfg(test)] +mod tests { + use super::account_name_from_filename; + + #[test] + fn main_account_from_bare_token() { + assert_eq!( + account_name_from_filename("matrix-token").as_deref(), + Some("main") + ); + } + + #[test] + fn extra_account_from_suffixed_token() { + assert_eq!( + account_name_from_filename("matrix-token-catgirl").as_deref(), + Some("catgirl") + ); + } + + #[test] + fn empty_suffix_is_not_an_account() { + assert_eq!(account_name_from_filename("matrix-token-"), None); + } + + #[test] + fn non_token_files_ignored() { + assert_eq!(account_name_from_filename("matrix-sdk-state"), None); + assert_eq!(account_name_from_filename("notes.md"), None); + assert_eq!(account_name_from_filename("matrix-avatar-icon-hash"), None); + } +}