//! Matrix-account listing for the dashboard (BE-1 of the external //! matrix-account provisioning backend). //! //! `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 (v2) come from the daemon's //! `matrix-accounts.json` snapshot (written at startup after restores): the //! response backfills `homeserver`, `user_id`, and `live` per account from //! it, and carries the snapshot's `as_of_unix` (file mtime) so a reader can //! judge freshness. An account with a token but absent from the snapshot //! reports `live: false` (provisioned but not restored / daemon down). use std::collections::HashMap; use std::path::Path; use axum::extract::{Form, 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, /// Effective homeserver, backfilled from the daemon snapshot; `None` when /// the account isn't in the snapshot (token present but not restored). homeserver: Option, /// A token file for this account exists in the agent state dir. token_present: bool, /// The account restored a live client per the daemon snapshot. `false` /// when provisioned but absent from the snapshot (not up / daemon down). live: bool, /// The account's matrix user id, from the snapshot when known. user_id: Option, } #[derive(Serialize)] struct MatrixAccountsResponse { accounts: Vec, /// Unix mtime of the daemon's `matrix-accounts.json` snapshot (when the /// live data was last published), or `None` when no snapshot exists yet. /// Lets the dashboard show "live as of N ago" without treating a stale /// snapshot as definitely down. as_of_unix: Option, } /// One entry of the daemon's `matrix-accounts.json` snapshot /// (`hive-matrix-mcp::accounts::AccountStatus`). Only the fields the /// dashboard backfills are read; extra fields (e.g. `is_primary`) are /// ignored by serde. #[derive(Deserialize)] struct SnapshotAccount { name: String, homeserver: String, #[serde(default)] user_id: Option, live: bool, } /// Read the daemon's live-account snapshot from the agent state dir. Returns /// the per-name entries plus the file's unix mtime (`as_of`). A missing or /// unparseable file yields an empty map (every provisioned account then /// reports `live: false`); `as_of` is `None` only when the file is absent. fn read_accounts_snapshot(dir: &Path) -> (HashMap, Option) { let path = dir.join("matrix-accounts.json"); let as_of = std::fs::metadata(&path) .and_then(|m| m.modified()) .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)); let map = std::fs::read_to_string(&path) .ok() .and_then(|s| serde_json::from_str::>(&s).ok()) .map(|v| v.into_iter().map(|a| (a.name.clone(), a)).collect()) .unwrap_or_default(); (map, as_of) } /// 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 (snapshot, as_of_unix) = read_accounts_snapshot(&dir); 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) { // Backfill live status + homeserver + user id from the // daemon snapshot; absent => provisioned but not restored. let snap = snapshot.get(&name); accounts.push(MatrixAccount { homeserver: snap.map(|s| s.homeserver.clone()), token_present: true, live: snap.is_some_and(|s| s.live), user_id: snap.and_then(|s| s.user_id.clone()), name, }); } } } // 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, as_of_unix, }) .into_response() } /// Form body for `POST /matrix-account-login` (urlencoded, the dashboard's /// mutation convention). `mode` is `"password"` (needs `user_id` + /// `password`) or `"token"` (needs `token`; `user_id` is recovered via /// whoami). #[derive(Deserialize)] pub(super) struct MatrixLoginForm { agent: String, account: String, homeserver: String, mode: String, user_id: Option, password: Option, token: Option, } #[derive(Serialize)] struct MatrixLoginResult { ok: bool, user_id: String, } /// Plain-identifier check matching hive-priv's `validate_name_chars` /// exactly (lowercase ascii + digits + hyphens) — the root-side guard /// re-applies the same rule before building the token path. Keeping the /// dashboard check identical means a name that passes here can't then be /// rejected at the priv boundary with a confusing "write token failed". fn is_plain_ident(s: &str) -> bool { !s.is_empty() && s.chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') } /// Provision (or refresh) the token for an agent's extra matrix account. /// password mode → `m.login.password`; token mode → validate via `whoami`. /// On success writes the token to `matrix-token-` via hive-priv and /// kicks the daemon. Operator-authenticated (dashboard). Never echoes the /// token back — only `{ ok, user_id }`. pub(super) async fn post_matrix_account_login(Form(f): Form) -> Response { let agent = f.agent.trim(); let account = f.account.trim(); let homeserver = f.homeserver.trim().trim_end_matches('/'); if !is_plain_ident(agent) { return error_response(&format!("matrix-account-login: invalid agent {agent:?}")); } if !is_plain_ident(account) { return error_response(&format!( "matrix-account-login: invalid account {account:?}" )); } if account == "main" { return error_response( "matrix-account-login: 'main' is the hive-internal account; it is \ provisioned via the normal flow, not this form", ); } if !(homeserver.starts_with("http://") || homeserver.starts_with("https://")) { return error_response(&format!( "matrix-account-login: homeserver must be an http(s) URL, got {homeserver:?}" )); } let (token, user_id) = match f.mode.as_str() { "password" => { let (Some(uid), Some(pw)) = (f.user_id.as_deref(), f.password.as_deref()) else { return error_response( "matrix-account-login: password mode needs user_id + password", ); }; match matrix_password_login(homeserver, uid, pw).await { Ok(pair) => pair, Err(e) => return error_response(&format!("matrix-account-login: {e}")), } } "token" => { let Some(tok) = f.token.as_deref() else { return error_response("matrix-account-login: token mode needs token"); }; match matrix_whoami(homeserver, tok).await { Ok(uid) => (tok.to_owned(), uid), Err(e) => return error_response(&format!("matrix-account-login: {e}")), } } other => { return error_response(&format!( "matrix-account-login: unknown mode {other:?} (want password|token)" )); } }; if let Err(e) = crate::priv_client::write_agent_matrix_token(agent, &token, Some(account), Some(homeserver)) .await { return error_response(&format!("matrix-account-login: write token failed: {e:#}")); } // Best-effort kick so the daemon picks up the new account without a full // container restart; not fatal if the container isn't running. if let Err(e) = crate::priv_client::restart_matrix_daemon(agent).await { tracing::warn!( %agent, %account, error = ?e, "matrix-account-login: daemon restart failed (token written; loads on next start)" ); } tracing::info!(%agent, %account, "matrix-account-login: provisioned extra matrix account"); axum::Json(MatrixLoginResult { ok: true, user_id }).into_response() } /// POST `m.login.password` to `/_matrix/client/v3/login`. /// Returns `(access_token, user_id)`. async fn matrix_password_login( homeserver: &str, user_id: &str, password: &str, ) -> Result<(String, String), String> { let url = format!("{homeserver}/_matrix/client/v3/login"); let body = serde_json::json!({ "type": "m.login.password", "identifier": { "type": "m.id.user", "user": user_id }, "password": password, "initial_device_display_name": "hyperhive", }); let resp = reqwest::Client::new() .post(&url) .json(&body) .send() .await .map_err(|e| format!("POST /login: {e}"))?; let status = resp.status(); let json: serde_json::Value = resp .json() .await .map_err(|e| format!("parse /login response: {e}"))?; if !status.is_success() { let err = json .get("error") .and_then(serde_json::Value::as_str) .unwrap_or("login failed"); return Err(format!("/login HTTP {status}: {err}")); } let token = json .get("access_token") .and_then(serde_json::Value::as_str) .ok_or_else(|| "login response missing access_token".to_owned())?; let uid = json .get("user_id") .and_then(serde_json::Value::as_str) .ok_or_else(|| "login response missing user_id".to_owned())?; Ok((token.to_owned(), uid.to_owned())) } /// GET `/_matrix/client/v3/account/whoami` with the bearer token /// to validate it and recover the `user_id`. async fn matrix_whoami(homeserver: &str, token: &str) -> Result { let url = format!("{homeserver}/_matrix/client/v3/account/whoami"); let resp = reqwest::Client::new() .get(&url) .bearer_auth(token) .send() .await .map_err(|e| format!("GET /whoami: {e}"))?; let status = resp.status(); let json: serde_json::Value = resp .json() .await .map_err(|e| format!("parse /whoami response: {e}"))?; if !status.is_success() { let err = json .get("error") .and_then(serde_json::Value::as_str) .unwrap_or("token rejected"); return Err(format!("/whoami HTTP {status}: {err}")); } json.get("user_id") .and_then(serde_json::Value::as_str) .map(ToOwned::to_owned) .ok_or_else(|| "whoami response missing user_id".to_owned()) } #[cfg(test)] mod tests { use super::{account_name_from_filename, is_plain_ident, read_accounts_snapshot}; fn unique_dir(tag: &str) -> std::path::PathBuf { let d = std::env::temp_dir().join(format!( "hh-c0re-{tag}-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); std::fs::create_dir_all(&d).unwrap(); d } #[test] fn snapshot_parses_entries_and_reports_as_of() { let dir = unique_dir("snap"); std::fs::write( dir.join("matrix-accounts.json"), r#"[ {"name":"main","homeserver":"http://hs","user_id":"@a:hs","live":true,"is_primary":true}, {"name":"pub","homeserver":"https://matrix.org","user_id":null,"live":true,"is_primary":false} ]"#, ) .unwrap(); let (map, as_of) = read_accounts_snapshot(&dir); assert_eq!(map.len(), 2); assert_eq!(map["main"].homeserver, "http://hs"); assert!(map["main"].live); assert_eq!(map["main"].user_id.as_deref(), Some("@a:hs")); assert_eq!(map["pub"].user_id, None); // is_primary is present in the file but ignored — no panic on the // extra field. assert!(as_of.is_some()); std::fs::remove_dir_all(&dir).ok(); } #[test] fn snapshot_absent_yields_empty_map_and_no_as_of() { let dir = unique_dir("nosnap"); let (map, as_of) = read_accounts_snapshot(&dir); assert!(map.is_empty()); assert!(as_of.is_none()); std::fs::remove_dir_all(&dir).ok(); } #[test] fn snapshot_unparseable_is_empty_but_as_of_set() { let dir = unique_dir("badsnap"); std::fs::write(dir.join("matrix-accounts.json"), "not json").unwrap(); let (map, as_of) = read_accounts_snapshot(&dir); assert!(map.is_empty()); // File exists, so freshness is still reported even though it can't // be parsed (every account then reports live: false). assert!(as_of.is_some()); std::fs::remove_dir_all(&dir).ok(); } #[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); } #[test] fn is_plain_ident_matches_validate_name_chars() { // Accepts exactly what hive-priv's validate_name_chars does: // lowercase ascii + digits + hyphens. assert!(is_plain_ident("catgirl")); assert!(is_plain_ident("acct-1")); assert!(!is_plain_ident("")); // Rejected: uppercase + underscore (would pass a looser check // then fail at the priv boundary), and path chars. assert!(!is_plain_ident("MyAccount")); assert!(!is_plain_ident("my_account")); assert!(!is_plain_ident("../escape")); assert!(!is_plain_ident("a/b")); assert!(!is_plain_ident("a.b")); } }