//! 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 utoipa::{IntoParams, ToSchema}; use super::{Ident, error_response}; use crate::coordinator::Coordinator; #[derive(Deserialize, IntoParams)] pub(super) struct MatrixAccountsQuery { agent: String, } #[derive(Serialize, ToSchema)] 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, ToSchema)] 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()) } /// Matrix accounts provisioned /// for `agent`. /// /// Backfilled with `homeserver`/`live`/`user_id` from the daemon's /// snapshot. #[utoipa::path( get, path = "/api/matrix-accounts", params(MatrixAccountsQuery), responses( (status = 200, description = "provisioned matrix accounts for the agent", body = MatrixAccountsResponse), (status = 500, description = "invalid agent name, or a state-dir read failed"), ), tag = "matrix_accounts" )] pub(super) async fn get_matrix_accounts(Query(q): Query) -> Response { let agent = q.agent.trim(); // Validate through the single `Ident` type so a crafted `agent` can't // escape the per-agent state root via path components — the same guard // every other agent-path builder goes through. let Ok(agent) = Ident::parse(agent) else { 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, ToSchema)] pub(super) struct MatrixLoginForm { agent: String, account: String, homeserver: String, mode: String, user_id: Option, password: Option, token: Option, } #[derive(Serialize, ToSchema)] struct MatrixLoginResult { ok: bool, user_id: String, } /// 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 }`. #[utoipa::path( post, path = "/api/matrix-account-login", request_body(content = MatrixLoginForm, content_type = "application/x-www-form-urlencoded"), responses( (status = 200, description = "account provisioned", body = MatrixLoginResult), (status = 500, description = "invalid input, or the homeserver login/whoami failed"), ), tag = "matrix_accounts" )] 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('/'); let Ok(agent) = Ident::parse(agent) else { return error_response(&format!("matrix-account-login: invalid agent {agent:?}")); }; let Ok(account) = Ident::parse(account) else { return error_response(&format!( "matrix-account-login: invalid account {account:?}" )); }; if account.as_str() == "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.as_str(), &token, Some(account.as_str()), 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.as_str()).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() } /// Form body for `POST /api/github-account` (urlencoded, the dashboard's /// mutation convention). Writes the operator-supplied PAT to the agent's /// `github-token` file. The GitHub counterpart of the matrix login form, but /// far simpler: no account creation, no homeserver, no login modes — the /// operator pastes a PAT for an existing account. #[derive(Deserialize, ToSchema)] pub(super) struct GithubAccountForm { agent: String, token: String, } #[derive(Serialize, ToSchema)] struct GithubAccountResult { ok: bool, } /// Provision (or refresh) an agent's GitHub PAT from the dashboard /// credentials tab. /// /// Validates the agent name, then writes the PAT to /// `/github-token` (`0600`, agent-owned) via hive-priv. No account /// creation and no daemon to kick — the agent's `gh` wrapper / git credential /// helper read the file live, so the new token takes effect immediately. /// Operator-authenticated (dashboard). Never echoes the token back — only /// `{ ok: true }`. #[utoipa::path( post, path = "/api/github-account", request_body(content = GithubAccountForm, content_type = "application/x-www-form-urlencoded"), responses( (status = 200, description = "PAT provisioned", body = GithubAccountResult), (status = 500, description = "invalid agent name, empty token, or the write failed"), ), tag = "matrix_accounts" )] pub(super) async fn post_github_account(Form(f): Form) -> Response { let agent = f.agent.trim(); let token = f.token.trim(); let Ok(agent) = Ident::parse(agent) else { return error_response(&format!("github-account: invalid agent {agent:?}")); }; if token.is_empty() { return error_response("github-account: token is required"); } if let Err(e) = crate::priv_client::write_agent_github_token(agent.as_str(), token).await { return error_response(&format!("github-account: write token failed: {e:#}")); } tracing::info!(%agent, "github-account: provisioned github PAT"); axum::Json(GithubAccountResult { ok: true }).into_response() } #[derive(Deserialize, IntoParams)] pub(super) struct GithubAccountQuery { agent: String, } #[derive(Serialize, ToSchema)] struct GithubAccountStatus { /// A `github-token` file exists in the agent's state dir (a PAT has been /// provisioned). A static PAT has no live/heartbeat concept, so this is /// the only status the credentials tab needs. present: bool, } /// Whether the agent has a GitHub /// PAT provisioned (its `github-token` file exists). /// /// Lets the credentials tab show "token stored" vs "not set" instead of a /// black-hole paste field. Never returns the token itself. #[utoipa::path( get, path = "/api/github-account", params(GithubAccountQuery), responses( (status = 200, description = "whether a github PAT is provisioned", body = GithubAccountStatus), (status = 500, description = "invalid agent name"), ), tag = "matrix_accounts" )] pub(super) async fn get_github_account(Query(q): Query) -> Response { let agent = q.agent.trim(); let Ok(agent) = Ident::parse(agent) else { return error_response(&format!("github-account: invalid agent {agent:?}")); }; let present = Coordinator::agent_notes_dir(&agent) .join("github-token") .exists(); axum::Json(GithubAccountStatus { present }).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, 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); } }