diff --git a/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.tsx b/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.tsx
index e27f5750..081c0b81 100644
--- a/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.tsx
+++ b/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.tsx
@@ -1,11 +1,17 @@
-// — writes a matrix account credential (token +
-// optional homeserver) for one agent into the swarm secret store.
-// PUTs `/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}` per
-// the contract atlas posted on the OpenBao swarm-secret-store adoption
-// issue: `{ token, homeserver? }` body, 200 on success, 400/503/500 on
-// the documented failure arms — all handled generically via
-// `readApiError`/`ApiErrorPanel`, same as every other form here, since
-// the response is `problem+json` regardless of which arm fired.
+// — writes a matrix account credential for one
+// agent into the swarm secret store, in one of two modes (mirrors
+// swarm-controller's `PutMatrixAccountRequest`):
+// token — paste an already-obtained bearer token, optional homeserver.
+// password — user id + password; swarm-controller logs into the
+// (required, here) homeserver itself and stores the token
+// that comes back. The password is sent once, over this
+// PUT, straight to swarm-controller — never held here past
+// the request, never sent to the hive.
+// PUTs `/api/hives/{hive}/agents/{agent}/matrix-accounts/{account}` —
+// 200 (`{ user_id? }`) on success, 400/503/500 on the documented failure
+// arms — all handled generically via `readApiError`/`ApiErrorPanel`,
+// same as every other form here, since the response is `problem+json`
+// regardless of which arm fired.
//
// No "linked accounts" list to show first: no route exposes one (a
// credential store shouldn't hand a secret back out anyway), so this is
@@ -15,12 +21,6 @@
// mara's rulings on that issue — there's no assignment step here yet,
// just the write.
//
-// Deliberately built against the contract before the endpoint exists
-// (atlas: "it does not change when the implementation lands") — until
-// item 2 merges this 404s, same as any other page pointed at a route
-// that isn't live yet. Nothing here depends on the backend being up to
-// be correct.
-//
// Rendered inside a `Dialog` from `AgentsPage`, one per row — same
// mount shape as `CreateAgentForm`, own file for the same reason (a
// multi-field form with real submit-state handling is more than
@@ -31,9 +31,12 @@ import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Panel } from "../ui/panel/Panel.js";
import { TextField } from "../ui/text-field/TextField.js";
+import { SelectField } from "../ui/select-field/SelectField.js";
import { Button } from "../ui/button/Button.js";
import "./LinkMatrixAccountForm.css";
+type Mode = "token" | "password";
+
// The store's own key-space, not `hive_types::Ident` — atlas's measured
// gap on the issue: an agent name is an `Ident` (lowercase-only) but an
// account name is a bare attrset key under `matrixAccounts`, so it
@@ -45,9 +48,18 @@ const ACCOUNT_PATTERN = "[A-Za-z0-9_\\-]{1,63}";
type SubmitState =
| { status: "idle" }
| { status: "submitting" }
- | { status: "done" }
+ // `userId` is set in password mode (the server resolves it from the
+ // login response) and `undefined` in token mode — see
+ // `PutMatrixAccountResponse`'s own doc for why token mode doesn't
+ // spend a round trip resolving one.
+ | { status: "done"; userId?: string }
| { status: "error"; problem: ProblemDetails };
+const MODE_OPTIONS = [
+ { value: "token", label: "paste a token" },
+ { value: "password", label: "log in with a password" },
+];
+
export function LinkMatrixAccountForm({
hive,
agent,
@@ -56,7 +68,10 @@ export function LinkMatrixAccountForm({
agent: string;
}) {
const [account, setAccount] = useState("");
+ const [mode, setMode] = useState("token");
const [token, setToken] = useState("");
+ const [userId, setUserId] = useState("");
+ const [password, setPassword] = useState("");
const [homeserver, setHomeserver] = useState("");
const [result, setResult] = useState({ status: "idle" });
@@ -69,26 +84,43 @@ export function LinkMatrixAccountForm({
{
method: "PUT",
headers: { "content-type": "application/json" },
- body: JSON.stringify({
- token,
- // Omitted rather than sent empty — the contract marks it
- // optional, and an empty string isn't "no homeserver", it's
- // a homeserver named "".
- homeserver: homeserver.trim() || undefined,
- }),
+ body: JSON.stringify(
+ mode === "token"
+ ? {
+ mode,
+ token,
+ // Omitted rather than sent empty — the contract marks
+ // it optional in token mode, and an empty string
+ // isn't "no homeserver", it's a homeserver named "".
+ homeserver: homeserver.trim() || undefined,
+ }
+ : {
+ mode,
+ user_id: userId,
+ password,
+ // Required in password mode — the field below enforces
+ // that before this branch is ever reachable.
+ homeserver: homeserver.trim(),
+ },
+ ),
},
);
if (!r.ok) {
setResult({ status: "error", problem: await readApiError(r) });
return;
}
- setResult({ status: "done" });
- // Token cleared on success — nothing left needing it in the form
- // once the store holds it, and a secret sitting in a field after
- // the action that used it is exactly the kind of stale-credential
- // risk the whole store exists to avoid. Account name kept, so the
- // success message below reads against what actually landed.
+ const body = (await r.json().catch(() => ({}))) as {
+ user_id?: string;
+ };
+ setResult({ status: "done", userId: body.user_id });
+ // Secrets cleared on success — nothing left needing them in the
+ // form once the store holds the derived credential, and a secret
+ // sitting in a field after the action that used it is exactly the
+ // kind of stale-credential risk the whole store exists to avoid.
+ // Account name kept, so the success message below reads against
+ // what actually landed.
setToken("");
+ setPassword("");
} catch (err) {
setResult({ status: "error", problem: { detail: String(err) } });
}
@@ -118,20 +150,54 @@ export function LinkMatrixAccountForm({
required
onInput={setAccount}
/>
- setMode(v as Mode)}
/>
+ {mode === "token" ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
)}
{result.status === "error" && (
diff --git a/swarm-controller/src/matrix_account.rs b/swarm-controller/src/matrix_account.rs
index 626cb7f5..2e24303f 100644
--- a/swarm-controller/src/matrix_account.rs
+++ b/swarm-controller/src/matrix_account.rs
@@ -10,11 +10,21 @@
//! ⚠️ Store first, notify second, and the order cannot be swapped: a notice
//! that overtakes its own write reaches a hive that reads nothing, and the
//! hive deliberately does not retry.
+//!
+//! Two credential modes, chosen by `PutMatrixAccountRequest::mode`: `token`
+//! (default, back-compat with the original blind-store shape — the caller
+//! already has a bearer token) and `password` (this daemon performs
+//! `m.login.password` against the caller-given homeserver itself and stores
+//! the resulting token; the password is never stored, and is not sent to the
+//! hive either — only the derived token is). Mirrors what hive-c0re's own
+//! `/api/matrix-account-login` does for a *hive-local* account, done here
+//! instead so the browser never has to hold the password long enough to call
+//! an arbitrary homeserver directly.
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
-use serde::Deserialize;
+use serde::{Deserialize, Serialize};
use swarm_queue_client::{CredentialNotice, credential_subject};
use swarm_secret_client::{SecretStore, matrix};
use utoipa::ToSchema;
@@ -30,20 +40,54 @@ use super::{AppState, error_problem, swarm_hive};
/// (`allowed_common_names`), so the two are deliberately different strings.
const CERT_ROLE: &str = "swarm-controller";
+fn default_mode() -> String {
+ "token".to_owned()
+}
+
/// The credential to store for one agent's external matrix account.
#[derive(Debug, Deserialize, ToSchema)]
pub struct PutMatrixAccountRequest {
- /// The access token. Never logged, and never returned by this route.
- token: String,
+ /// `"token"` (default, back-compat) or `"password"`. Token mode stores
+ /// `token` as given; password mode logs into `homeserver` with `user_id`
+ /// + `password` and stores the token that comes back instead.
+ #[serde(default = "default_mode")]
+ #[schema(example = "token")]
+ mode: String,
+ /// The access token. Required (and used as given) in token mode; ignored
+ /// in password mode, where the token comes from the login instead. Never
+ /// logged, and never returned by this route.
+ token: Option,
+ /// Password-mode only: the matrix user id (or bare localpart) to log in
+ /// as.
+ user_id: Option,
+ /// Password-mode only. Never logged and never stored — only the token
+ /// `m.login.password` returns is.
+ password: Option,
/// The account's homeserver, when it is not this swarm's own.
///
/// Stored beside the token rather than sent on the notice: a notice is a
/// queue message, so a homeserver carried there would exist only in
/// flight, with nowhere to reconstruct it from on a re-delivery.
+ ///
+ /// Optional in token mode (omitted means "resolve to the agent's own
+ /// `hyperhive.matrix.url` on the hive side" — this route never needs to
+ /// know it itself for a blind store). **Required** in password mode:
+ /// logging in needs somewhere to log in against, and unlike token mode
+ /// there is no hive-side fallback to defer to.
#[schema(example = "https://matrix.example.org")]
homeserver: Option,
}
+/// `put_matrix_account`'s success body.
+#[derive(Debug, Serialize, ToSchema)]
+pub struct PutMatrixAccountResponse {
+ /// The account's matrix user id, recovered from the login response in
+ /// password mode. `None` in token mode — the caller already knows which
+ /// account their own token belongs to, and this route does not spend a
+ /// `whoami` round trip validating a token it was simply handed.
+ user_id: Option,
+}
+
/// Store an agent's external matrix account credential and notify its hive.
///
/// Idempotent: the store keeps versions, so repeating a call replaces the
@@ -58,8 +102,8 @@ pub struct PutMatrixAccountRequest {
),
request_body = PutMatrixAccountRequest,
responses(
- (status = 200, description = "stored, and the hive has been told"),
- (status = 400, description = "a name is not an identifier, the account name is not a single path segment, or the hive is not in this swarm (problem+json)", body = String),
+ (status = 200, description = "stored, and the hive has been told", body = PutMatrixAccountResponse),
+ (status = 400, description = "a name is not an identifier, the account name is not a single path segment, the account is 'main' (reserved), the mode is unrecognized, a mode's required fields are missing, or the hive is not in this swarm (problem+json)", body = String),
(status = 503, description = "no swarm queue is wired up (problem+json)", body = String),
(status = 500, description = "the store write, the encode or the publish failed (problem+json)", body = String),
),
@@ -69,7 +113,7 @@ pub async fn put_matrix_account(
State(state): State,
axum::extract::Path((hive, agent, account)): axum::extract::Path<(String, String, String)>,
Json(req): Json,
-) -> Result {
+) -> Result, problem_details::ProblemDetails> {
// The queue first, so a deployment that has none answers 503 whatever the
// caller spelled — and before the store is touched, so a request that
// could never be delivered does not leave a credential behind.
@@ -88,6 +132,59 @@ pub async fn put_matrix_account(
// charset is its rule to state, not this handler's to restate.
let secret_path = matrix::account_path(&agent, &account)
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
+ // `main` is the hive-internal account `nix/agent-modules/matrix.nix`
+ // synthesizes per agent from `hyperhive.matrix.url` — the schema there
+ // forbids declaring a key by that name for the same reason this route
+ // refuses to write one: an extra account literally named `main` would
+ // not overwrite the real one (it lands at a different token-file suffix)
+ // but would confuse anything that lists accounts by name. Same guard
+ // hive-c0re's own `/api/matrix-account-login` applies, checked before any
+ // mode-specific work (including a network login) runs.
+ if account == "main" {
+ return Err(error_problem(
+ StatusCode::BAD_REQUEST,
+ "'main' is the hive-internal account, synthesized per agent from \
+ hyperhive.matrix.url — it cannot be set through this route.",
+ ));
+ }
+
+ // Resolve the mode to a concrete (token, homeserver, resolved user id) —
+ // password mode's network call happens here, before the store is
+ // touched, so a failed login leaves no partial state behind.
+ let (token, homeserver, user_id) = match req.mode.as_str() {
+ "token" => {
+ let Some(token) = req.token else {
+ return Err(error_problem(
+ StatusCode::BAD_REQUEST,
+ "token mode needs a token",
+ ));
+ };
+ (token, req.homeserver, None)
+ }
+ "password" => {
+ let (Some(homeserver), Some(user_id), Some(password)) = (
+ req.homeserver.as_deref(),
+ req.user_id.as_deref(),
+ req.password.as_deref(),
+ ) else {
+ return Err(error_problem(
+ StatusCode::BAD_REQUEST,
+ "password mode needs homeserver, user_id, and password",
+ ));
+ };
+ let homeserver = homeserver.trim_end_matches('/').to_owned();
+ let (token, resolved_user_id) = matrix_password_login(&homeserver, user_id, password)
+ .await
+ .map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e))?;
+ (token, Some(homeserver), Some(resolved_user_id))
+ }
+ other => {
+ return Err(error_problem(
+ StatusCode::BAD_REQUEST,
+ &format!("unknown mode {other:?} (want token|password)"),
+ ));
+ }
+ };
let store = SecretStore::from_env(CERT_ROLE).await.map_err(|e| {
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
@@ -97,8 +194,8 @@ pub async fn put_matrix_account(
.write(
&secret_path,
&matrix::Credential {
- value: req.token,
- homeserver: req.homeserver,
+ value: token,
+ homeserver,
},
)
.await
@@ -140,5 +237,54 @@ pub async fn put_matrix_account(
})?;
tracing::info!(%subject, %hive, %agent, %account, "credential stored; hive notified");
- Ok(StatusCode::OK)
+ Ok(Json(PutMatrixAccountResponse { user_id }))
+}
+
+/// POST `m.login.password` to `/_matrix/client/v3/login`.
+/// Returns `(access_token, user_id)`.
+///
+/// Deliberately its own copy rather than a shared crate with
+/// `hive-c0re::dashboard::matrix_accounts`'s near-identical helper: the two
+/// log in on behalf of different callers (a hive's own dashboard vs. this
+/// swarm-wide provisioning route) and share no other code — four lines of
+/// JSON construction do not justify a dependency edge between them.
+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()))
}