diff --git a/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.tsx b/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.tsx
index 081c0b81..e27f5750 100644
--- a/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.tsx
+++ b/frontend/packages/swarm-ui/src/pages/LinkMatrixAccountForm.tsx
@@ -1,17 +1,11 @@
-// — 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.
+// — 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.
//
// 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
@@ -21,6 +15,12 @@
// 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,12 +31,9 @@ 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
@@ -48,18 +45,9 @@ const ACCOUNT_PATTERN = "[A-Za-z0-9_\\-]{1,63}";
type SubmitState =
| { status: "idle" }
| { status: "submitting" }
- // `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: "done" }
| { 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,
@@ -68,10 +56,7 @@ 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" });
@@ -84,43 +69,26 @@ export function LinkMatrixAccountForm({
{
method: "PUT",
headers: { "content-type": "application/json" },
- 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(),
- },
- ),
+ 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,
+ }),
},
);
if (!r.ok) {
setResult({ status: "error", problem: await readApiError(r) });
return;
}
- 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.
+ 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.
setToken("");
- setPassword("");
} catch (err) {
setResult({ status: "error", problem: { detail: String(err) } });
}
@@ -150,54 +118,20 @@ 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 31843bda..626cb7f5 100644
--- a/swarm-controller/src/matrix_account.rs
+++ b/swarm-controller/src/matrix_account.rs
@@ -10,21 +10,11 @@
//! ⚠️ 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, Serialize};
+use serde::Deserialize;
use swarm_queue_client::{CredentialNotice, credential_subject};
use swarm_secret_client::{SecretStore, matrix};
use utoipa::ToSchema;
@@ -40,58 +30,20 @@ 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.
-///
-/// No `Debug` derive — matching `hive-c0re::dashboard::matrix_accounts`'s
-/// analogous `MatrixLoginForm`, which also carries a password field and
-/// deliberately omits it so nothing can `{:?}`-log this by accident.
-#[derive(Deserialize, ToSchema)]
+#[derive(Debug, Deserialize, ToSchema)]
pub struct PutMatrixAccountRequest {
- /// `"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 access token. Never logged, and never returned by this route.
+ token: String,
/// 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
@@ -106,8 +58,8 @@ pub struct PutMatrixAccountResponse {
),
request_body = PutMatrixAccountRequest,
responses(
- (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 = 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 = 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),
),
@@ -117,7 +69,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, problem_details::ProblemDetails> {
+) -> Result {
// 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.
@@ -136,25 +88,6 @@ 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 is_reserved_account(&account) {
- 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.",
- ));
- }
-
- // 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) = resolve_credential(&req).await.map_err(|b| *b)?;
let store = SecretStore::from_env(CERT_ROLE).await.map_err(|e| {
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
@@ -164,8 +97,8 @@ pub async fn put_matrix_account(
.write(
&secret_path,
&matrix::Credential {
- value: token,
- homeserver,
+ value: req.token,
+ homeserver: req.homeserver,
},
)
.await
@@ -207,238 +140,5 @@ pub async fn put_matrix_account(
})?;
tracing::info!(%subject, %hive, %agent, %account, "credential stored; hive notified");
- Ok(Json(PutMatrixAccountResponse { user_id }))
-}
-
-/// Whether `account` is the hive-internal name every hive synthesizes per
-/// agent (`nix/agent-modules/matrix.nix`) — see the call site's own comment
-/// for why this route must never write one.
-fn is_reserved_account(account: &str) -> bool {
- account == "main"
-}
-
-/// Token-mode's only requirement: a token was actually given. Split out of
-/// `resolve_credential` (with `password_fields` below) so each mode's
-/// validation is unit-testable directly — only the actual network login in
-/// `resolve_credential` itself needs an async runtime to exercise. Returns a
-/// plain `&'static str` rather than a `ProblemDetails` — `clippy::result_large_err`
-/// (this workspace runs `pedantic = deny`) flags a private fn returning one of
-/// those directly; `put_matrix_account` itself is exempt only because it is
-/// `pub` (clippy's `avoid-breaking-exported-api` default), which these
-/// helpers are not.
-fn token_credential(
- req: &PutMatrixAccountRequest,
-) -> Result<(String, Option), &'static str> {
- let Some(token) = req.token.clone() else {
- return Err("token mode needs a token");
- };
- Ok((token, req.homeserver.clone()))
-}
-
-/// Password mode's required fields, validated present, with the
-/// homeserver's trailing slash already trimmed — this is what actually
-/// reaches `matrix_password_login`, not the caller's raw string, so the
-/// login URL below never ends up with a doubled `//`.
-struct PasswordFields<'a> {
- homeserver: String,
- user_id: &'a str,
- password: &'a str,
-}
-
-fn password_fields(req: &PutMatrixAccountRequest) -> Result, &'static str> {
- let (Some(homeserver), Some(user_id), Some(password)) = (
- req.homeserver.as_deref(),
- req.user_id.as_deref(),
- req.password.as_deref(),
- ) else {
- return Err("password mode needs homeserver, user_id, and password");
- };
- Ok(PasswordFields {
- homeserver: homeserver.trim_end_matches('/').to_owned(),
- user_id,
- password,
- })
-}
-
-/// Resolve a request's credential mode to a concrete `(token, homeserver,
-/// resolved user id)`. Extracted out of `put_matrix_account` to keep that
-/// function under `clippy::too_many_lines`, and so the mode branching has
-/// something to unit-test directly (via `token_credential`/`password_fields`
-/// above) instead of only through the full route.
-///
-/// `Box`ed error for the same `result_large_err` reason `token_credential`'s
-/// doc explains — this fn is private too, so it does not get `put_matrix_account`'s
-/// exported-API exemption. Unboxed at the one call site instead of changing
-/// `put_matrix_account`'s own (exempt, and part of the route's documented
-/// contract) return type.
-async fn resolve_credential(
- req: &PutMatrixAccountRequest,
-) -> Result<(String, Option, Option), Box> {
- match req.mode.as_str() {
- "token" => {
- let (token, homeserver) = token_credential(req)
- .map_err(|e| Box::new(error_problem(StatusCode::BAD_REQUEST, e)))?;
- Ok((token, homeserver, None))
- }
- "password" => {
- let fields = password_fields(req)
- .map_err(|e| Box::new(error_problem(StatusCode::BAD_REQUEST, e)))?;
- let (token, resolved_user_id) =
- matrix_password_login(&fields.homeserver, fields.user_id, fields.password)
- .await
- .map_err(|e| Box::new(error_problem(StatusCode::BAD_REQUEST, &e)))?;
- Ok((token, Some(fields.homeserver), Some(resolved_user_id)))
- }
- other => Err(Box::new(error_problem(
- StatusCode::BAD_REQUEST,
- &format!("unknown mode {other:?} (want token|password)"),
- ))),
- }
-}
-
-/// 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()))
-}
-
-#[cfg(test)]
-mod tests {
- use super::{
- PutMatrixAccountRequest, is_reserved_account, password_fields, resolve_credential,
- token_credential,
- };
-
- fn request(mode: &str) -> PutMatrixAccountRequest {
- PutMatrixAccountRequest {
- mode: mode.to_owned(),
- token: None,
- user_id: None,
- password: None,
- homeserver: None,
- }
- }
-
- #[test]
- fn main_is_reserved_but_nothing_else_is() {
- assert!(is_reserved_account("main"));
- assert!(!is_reserved_account("ops-relay"));
- // Case-sensitive on purpose: `matrixAccounts` is a nix attrset, so
- // `Main` is a distinct, legal key from the reserved `main`.
- assert!(!is_reserved_account("Main"));
- }
-
- #[test]
- fn token_mode_needs_a_token() {
- assert!(token_credential(&request("token")).is_err());
- }
-
- #[test]
- fn token_mode_passes_through_token_and_homeserver_unchanged() {
- let mut req = request("token");
- req.token = Some("t0k3n".to_owned());
- req.homeserver = Some("https://matrix.example.org".to_owned());
- let (token, homeserver) = token_credential(&req).expect("token + homeserver both given");
- assert_eq!(token, "t0k3n");
- assert_eq!(homeserver.as_deref(), Some("https://matrix.example.org"));
- }
-
- #[test]
- fn password_mode_needs_all_three_fields() {
- let mut req = request("password");
- assert!(password_fields(&req).is_err(), "none given");
- req.homeserver = Some("https://matrix.example.org".to_owned());
- assert!(
- password_fields(&req).is_err(),
- "still missing user_id + password"
- );
- req.user_id = Some("@a:matrix.example.org".to_owned());
- assert!(password_fields(&req).is_err(), "still missing password");
- req.password = Some("hunter2".to_owned());
- assert!(password_fields(&req).is_ok(), "now all three given");
- }
-
- #[test]
- fn password_mode_trims_a_trailing_slash_off_the_homeserver() {
- let mut req = request("password");
- req.homeserver = Some("https://matrix.example.org/".to_owned());
- req.user_id = Some("@a:matrix.example.org".to_owned());
- req.password = Some("hunter2".to_owned());
- let fields = password_fields(&req).expect("all three fields given");
- assert_eq!(fields.homeserver, "https://matrix.example.org");
- }
-
- #[test]
- fn password_mode_leaves_a_homeserver_with_no_trailing_slash_unchanged() {
- let mut req = request("password");
- req.homeserver = Some("https://matrix.example.org".to_owned());
- req.user_id = Some("@a:matrix.example.org".to_owned());
- req.password = Some("hunter2".to_owned());
- let fields = password_fields(&req).expect("all three fields given");
- assert_eq!(fields.homeserver, "https://matrix.example.org");
- }
-
- #[tokio::test]
- async fn resolve_credential_token_mode_end_to_end() {
- let mut req = request("token");
- req.token = Some("t0k3n".to_owned());
- let (token, homeserver, user_id) = resolve_credential(&req)
- .await
- .expect("token mode with a token given");
- assert_eq!(token, "t0k3n");
- assert_eq!(homeserver, None);
- assert_eq!(user_id, None);
- }
-
- #[tokio::test]
- async fn resolve_credential_rejects_an_unknown_mode() {
- assert!(
- resolve_credential(&request("carrier-pigeon"))
- .await
- .is_err()
- );
- }
+ Ok(StatusCode::OK)
}