diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index cf7bc0c9..8267603f 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -75,13 +75,16 @@ request: swarm-controller removes every version of the entry from the swarm secret store, and answers 404 when the store holds nothing there. It refuses `main`: the swarm mints that account and would re-mint it. The panel requests the list -again after a delete. +again after a delete, including a 404 one: the account being already gone is +the end state the delete wanted, so the dialog closes without an error. The matrix confirmation has a checkbox, off by default, that logs the token -out at its homeserver first (`?revoke=true`). If the homeserver doesn't -confirm the logout, or the account has no homeserver stored, the account stays -in the store and the dialog shows why. Deleting a forge account or GitHub token -leaves the token valid at its provider; revoke it there. +out at its homeserver first (`?revoke=true`). A 401 `M_UNKNOWN_TOKEN` from +that logout means the homeserver already doesn't recognise the token, so the +delete proceeds as if it had succeeded. Any other failed logout, or an account +with no homeserver stored, leaves the account in the store and the dialog +shows why. Deleting a forge account or GitHub token leaves the token valid at +its provider; revoke it there. The agent isn't told about a delete. Its matrix daemon drops the account when it next lists the store, within two minutes. Its forge and GitHub units never diff --git a/frontend/packages/swarm-ui/src/pages/agents/LinkedAccounts.tsx b/frontend/packages/swarm-ui/src/pages/agents/LinkedAccounts.tsx index 33f123c1..b645509b 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/LinkedAccounts.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/LinkedAccounts.tsx @@ -96,7 +96,9 @@ export function LinkedAccounts({ deleteTarget.kind === "matrix" && revoke ? `${url}?revoke=true` : url, { method: "DELETE" }, ); - if (!r.ok) { + // A 404 means the account the dialog is about is already gone — the + // end state the delete wanted — so it closes the same as a success. + if (!r.ok && r.status !== 404) { setDeleteError(await readApiError(r)); return; } diff --git a/swarm-controller/README.md b/swarm-controller/README.md index 8b1016c3..566b0a4e 100644 --- a/swarm-controller/README.md +++ b/swarm-controller/README.md @@ -34,6 +34,9 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients. `.../forge-accounts/{label}`, `.../github-account`); distinct from the agent's own swarm-minted accounts above. `GET .../linked-accounts` lists them, plus the agent's own `main` matrix account, by kind, name and host, never with a credential. + Each of the three also takes a `DELETE`; matrix's also takes + `?revoke=true`, which logs the stored token out at its homeserver first and + leaves the account in place if that fails. - **Config PR status** — each agent's open config-repo PR, cached from forge webhooks (`GET /api/config-prs`, `/api/agents/{name}/config-pr`). - **Swarm-wide forge objects and webhooks** → diff --git a/swarm-controller/src/matrix_account.rs b/swarm-controller/src/matrix_account.rs index 05411f98..d5dca2c9 100644 --- a/swarm-controller/src/matrix_account.rs +++ b/swarm-controller/src/matrix_account.rs @@ -359,6 +359,10 @@ async fn matrix_password_login( /// POST `/_matrix/client/v3/logout` with `token`, which /// invalidates that token at the homeserver. /// +/// A 401 `M_UNKNOWN_TOKEN` means the homeserver already does not recognise +/// the token — the state a logout wants — so that one answer counts as +/// success rather than failure. Every other non-2xx is a failure. +/// /// The error names the status and the homeserver's `error` text, never the /// token. pub(crate) async fn matrix_logout(homeserver: &str, token: &str) -> Result<(), String> { @@ -377,10 +381,17 @@ pub(crate) async fn matrix_logout(homeserver: &str, token: &str) -> Result<(), S if status.is_success() { return Ok(()); } - let err = resp - .json::() - .await - .ok() + let body = resp.json::().await.ok(); + if status == StatusCode::UNAUTHORIZED + && body + .as_ref() + .and_then(|j| j.get("errcode")) + .and_then(serde_json::Value::as_str) + == Some("M_UNKNOWN_TOKEN") + { + return Ok(()); + } + let err = body .and_then(|j| { j.get("error") .and_then(serde_json::Value::as_str) @@ -394,7 +405,7 @@ pub(crate) async fn matrix_logout(homeserver: &str, token: &str) -> Result<(), S mod tests { use super::{ PutMatrixAccountRequest, homeserver_or_configured_default, is_reserved_account, - password_fields, resolve_credential, token_credential, + matrix_logout, password_fields, resolve_credential, token_credential, }; fn request(mode: &str) -> PutMatrixAccountRequest { @@ -600,4 +611,46 @@ mod tests { "{problem:?}" ); } + + /// A stand-in homeserver on a loopback port, answering every + /// `/_matrix/client/v3/logout` with `status` and `body`. + async fn stub_homeserver(status: u16, body: &'static str) -> String { + let app = axum::Router::new().route( + "/_matrix/client/v3/logout", + axum::routing::post(move || async move { + ( + axum::http::StatusCode::from_u16(status).unwrap(), + [(axum::http::header::CONTENT_TYPE, "application/json")], + body, + ) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await }); + format!("http://{addr}") + } + + /// A revoke whose token the homeserver already does not recognise has + /// reached logout's end state, not failed it. + #[tokio::test] + async fn logout_treats_401_m_unknown_token_as_already_revoked() { + let homeserver = stub_homeserver( + 401, + r#"{"errcode":"M_UNKNOWN_TOKEN","error":"Access token invalid"}"#, + ) + .await; + assert!(matrix_logout(&homeserver, "t0k3n").await.is_ok()); + } + + /// The control: a 401 with a different errcode is still a failure, so + /// the fold above is specific to `M_UNKNOWN_TOKEN` and not any 401. + #[tokio::test] + async fn logout_fails_on_other_401s() { + let homeserver = stub_homeserver(401, r#"{"errcode":"M_FORBIDDEN","error":"nope"}"#).await; + let err = matrix_logout(&homeserver, "t0k3n") + .await + .expect_err("not M_UNKNOWN_TOKEN"); + assert!(err.contains("401"), "{err}"); + } }