//! Per-agent GitHub PAT provisioning for the dashboard's GITHUB tab: //! `POST /api/github-account` stores it, `GET /api/github-account` reports //! whether one is stored. 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; /// 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. No account creation and 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() }