diff --git a/desktop/src-tauri/src/commands/admin/direct_action_tests.rs b/desktop/src-tauri/src/commands/admin/direct_action_tests.rs new file mode 100644 index 0000000000..d9f18af18c --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/direct_action_tests.rs @@ -0,0 +1,206 @@ +//! Direct moderation action tests (a child of `tests`, split for the size ratchet). + +use super::*; + +const SIGNER: &str = "11111111111111111111111111111111111111111111111111111111111111aa"; + +fn intent(action: DirectAction, expiration_secs: Option) -> AdminDirectIntent { + AdminDirectIntent { + origin: "https://admin.example.com".to_string(), + expected_relay: "wss://relay.example.com".to_string(), + expected_pubkey: SIGNER.to_string(), + community_host: "Team.Example.com".to_string(), + action, + target: "ab".repeat(32), + request_id: uuid::Uuid::nil(), + reason: Some("spam".to_string()), + expiration_secs, + } +} + +const BASE: &str = "https://relay.example.com"; + +#[test] +fn direct_action_sends_the_typed_host_only_as_the_query() { + let (url, body) = + direct_action_request(&intent(DirectAction::Timeout, Some(60)), BASE, SIGNER).unwrap(); + let t = "ab".repeat(32); + assert!( + url.ends_with(&format!( + "/api/admin/v1/members/{t}/timeout?communityHost=team.example.com" + )), + "{url}" + ); + let body: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + body, + serde_json::json!({ + "requestId": uuid::Uuid::nil(), + "reason": "spam", + "expirationSecs": 60, + }) + ); + let (url, body) = + direct_action_request(&intent(DirectAction::Delete, None), BASE, SIGNER).unwrap(); + assert!(url.contains(&format!("/events/{t}/delete?")), "{url}"); + assert!(!String::from_utf8(body).unwrap().contains("expirationSecs")); +} + +#[test] +fn direct_action_refuses_a_changed_signer_or_relay() { + // Confirmed under SIGNER on relay.example.com; each mismatch fails pre-send. + let other = "22".repeat(32); + for (signer, base) in [ + (other.as_str(), BASE), + (SIGNER, "https://relay-b.example.com"), + ] { + assert!(direct_action_request(&intent(DirectAction::Ban, None), base, signer).is_err()); + } + let mut blank = intent(DirectAction::Ban, None); + blank.expected_pubkey = " ".to_string(); + assert!(direct_action_request(&blank, BASE, SIGNER).is_err()); +} + +#[test] +fn direct_action_rejects_bad_host_target_and_duration() { + let mut bad_host = intent(DirectAction::Ban, None); + bad_host.community_host = "https://team.example.com/x".to_string(); + let mut bad_target = intent(DirectAction::Ban, None); + bad_target.target = "AB".repeat(32); + for bad in [ + bad_host, + bad_target, + intent(DirectAction::Timeout, None), + intent(DirectAction::Timeout, Some(0)), + intent(DirectAction::Ban, Some(60)), + ] { + assert!( + direct_action_request(&bad, BASE, SIGNER).is_err(), + "{bad:?}" + ); + } +} + +/// AppState signing as `keys` on relay.example.com, plus a direct intent +/// confirmed under `keys` that targets the loopback admin origin at `addr`. +fn direct_state_and_intent( + keys: &nostr::Keys, + addr: std::net::SocketAddr, + reason: &str, +) -> (crate::app_state::AppState, AdminDirectIntent) { + let state = crate::app_state::build_app_state(); + *state.keys.lock().unwrap() = keys.clone(); + *state.relay_url_override.lock().unwrap() = Some("wss://relay.example.com".to_string()); + let mut intent = intent(DirectAction::Ban, None); + intent.origin = format!("http://127.0.0.1:{}", addr.port()); + intent.expected_pubkey = keys.public_key().to_hex(); + intent.reason = Some(reason.to_string()); + (state, intent) +} + +/// Mutation evidence: removing the guard call in `send_admin_mutation` lets +/// both key-backup reasons reach the listener and flips the count RED. +#[tokio::test] +async fn direct_action_refuses_key_backup_reason_before_any_request() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let hits = Arc::new(AtomicUsize::new(0)); + let seen = Arc::clone(&hits); + let addr = serve_sequence_inspect( + vec![( + "200 OK", + "Content-Type: application/json\r\n", + r#"{"state":"applied"}"#, + )], + Some(Arc::new(move |_, _| { + seen.fetch_add(1, Ordering::SeqCst); + })), + ) + .await; + let keys = nostr::Keys::generate(); + for reason in ["see ncryptsec1qgg9947", "see NCRYPTSEC1QGG9947"] { + let (state, intent) = direct_state_and_intent(&keys, addr, reason); + let err = send_direct_action(&intent, keys.clone(), &state) + .await + .unwrap_err(); + assert!(format!("{err:?}").contains("key-backup"), "{err:?}"); + assert!( + err.not_sent, + "a pre-send key-backup refusal must be notSent" + ); + } + assert_eq!( + hits.load(Ordering::SeqCst), + 0, + "a key backup reached the relay" + ); + let (state, intent) = direct_state_and_intent(&keys, addr, "spam"); + send_direct_action(&intent, keys.clone(), &state) + .await + .expect("an ordinary reason still sends"); + assert_eq!(hits.load(Ordering::SeqCst), 1); +} + +/// The identity is swapped to B after the snapshot is taken; the first send +/// and the 401 retry must both still be signed by the confirmed key A. +/// +/// Mutation evidence: signing with a re-read `state.signing_keys()` inside +/// `send_direct_action` (the pre-fix shape) signs as B and flips this RED. +#[tokio::test] +async fn direct_action_signs_only_with_the_validated_snapshot() { + use std::sync::Mutex; + let auths: Arc>> = Arc::new(Mutex::new(Vec::new())); + let rec = Arc::clone(&auths); + let addr = serve_sequence_inspect( + vec![ + ("401 Unauthorized", "WWW-Authenticate: Nostr\r\n", ""), + ( + "200 OK", + "Content-Type: application/json\r\n", + r#"{"state":"applied"}"#, + ), + ], + Some(Arc::new(move |_, bytes: &[u8]| { + let text = String::from_utf8_lossy(bytes); + let auth = text + .lines() + .find_map(|l| { + l.strip_prefix("authorization: ") + .or(l.strip_prefix("Authorization: ")) + }) + .expect("NIP-98 header present"); + let pubkey = decode_nip98_event(auth.trim())["pubkey"] + .as_str() + .unwrap() + .to_string(); + rec.lock().unwrap().push(pubkey); + })), + ) + .await; + let a = nostr::Keys::generate(); + let (state, intent) = direct_state_and_intent(&a, addr, "spam"); + let snapshot = state.signing_keys().unwrap(); + *state.keys.lock().unwrap() = nostr::Keys::generate(); // identity import of B + send_direct_action(&intent, snapshot, &state) + .await + .expect("A-confirmed action sends as A"); + assert_eq!(*auths.lock().unwrap(), vec![a.public_key().to_hex(); 2]); +} + +/// A refusal before any request is typed `notSent`, so the UI can drop the +/// intent instead of offering a Retry that can never succeed. +/// +/// Mutation evidence: mapping the refusal through `AdminMutationError::from` +/// (plain `?`) leaves `not_sent` false and flips this RED. +#[tokio::test] +async fn direct_action_marks_a_pre_send_refusal_not_sent() { + let keys = nostr::Keys::generate(); + let addr = serve_sequence_inspect(vec![], None).await; + let (state, mut intent) = direct_state_and_intent(&keys, addr, "spam"); + intent.community_host = "team.example.com/".to_string(); + let err = send_direct_action(&intent, keys, &state).await.unwrap_err(); + assert!(err.not_sent, "{err:?}"); + assert_eq!( + serde_json::to_value(&err).unwrap()["notSent"], + serde_json::json!(true) + ); +} diff --git a/desktop/src-tauri/src/commands/admin/error.rs b/desktop/src-tauri/src/commands/admin/error.rs index 75e3f3a370..880fdef32a 100644 --- a/desktop/src-tauri/src/commands/admin/error.rs +++ b/desktop/src-tauri/src/commands/admin/error.rs @@ -17,6 +17,10 @@ /// the size cap) carries `bodyComplete: false`: the outcome is unknown, so the /// caller preserves idempotency and lets the retry dedupe even on a 4xx. /// +/// `notSent` is `true` only when the desktop refused the request before sending +/// it (a malformed intent, or the relay or signer changed): nothing reached the +/// relay, so retrying the same intent can never succeed. +/// /// Serialises `rename_all = "camelCase"`; the JS bridge surfaces it as the /// rejected `TauriInvokeError.payload`, from which the UI reads `relayStatus` /// and `bodyComplete`. @@ -32,9 +36,19 @@ pub struct AdminMutationError { /// Whether the relay's full response body was read. `true` only for an /// authoritative verdict; `false` when the body was lost or truncated. pub body_complete: bool, + /// Refused before sending; see the type docs. + pub not_sent: bool, } impl AdminMutationError { + /// Refused before any request was sent; retrying the same intent is futile. + pub(super) fn not_sent(message: String) -> Self { + Self { + not_sent: true, + ..Self::from(message) + } + } + /// The relay answered with an HTTP status and its full body was read — an /// authoritative verdict. pub(super) fn authoritative(status: reqwest::StatusCode, message: String) -> Self { @@ -42,6 +56,7 @@ impl AdminMutationError { message, relay_status: Some(status.as_u16()), body_complete: true, + not_sent: false, } } @@ -52,6 +67,7 @@ impl AdminMutationError { message, relay_status: Some(status.as_u16()), body_complete: false, + not_sent: false, } } } @@ -64,6 +80,7 @@ impl From for AdminMutationError { message, relay_status: None, body_complete: false, + not_sent: false, } } } diff --git a/desktop/src-tauri/src/commands/admin/helpers.rs b/desktop/src-tauri/src/commands/admin/helpers.rs index 079dcaba17..1bc64458e1 100644 --- a/desktop/src-tauri/src/commands/admin/helpers.rs +++ b/desktop/src-tauri/src/commands/admin/helpers.rs @@ -109,11 +109,32 @@ pub(super) async fn mutation_admin_json( state: &tauri::State<'_, crate::app_state::AppState>, ) -> Result, AdminMutationError> { let keys = state.signing_keys()?; + send_admin_mutation(&keys, method, url, body, cap).await +} + +/// State-free core of [`mutation_admin_json`]: every request, including the +/// 401 retry, is signed by the caller's `keys` snapshot and never re-reads the +/// active identity. The body passes the key-backup egress guard before any +/// request is built, so free-text admin fields cannot carry an `ncryptsec`. +pub(super) async fn send_admin_mutation( + keys: &nostr::Keys, + method: reqwest::Method, + url: &str, + body: Option<&[u8]>, + cap: u64, +) -> Result, AdminMutationError> { + // Every refusal before the first `.send()` is `not_sent`: nothing reached + // the relay, so resending the same intent can never succeed. + if let Some(bytes) = body { + crate::egress_guard::assert_no_key_backup_bytes(bytes, "admin API mutation") + .map_err(AdminMutationError::not_sent)?; + } let http_client = client::ADMIN_CLIENT .get() - .ok_or_else(|| "admin client not initialised".to_string())?; + .ok_or_else(|| AdminMutationError::not_sent("admin client not initialised".to_string()))?; - let resp = build_admin_mutation_request(http_client, &keys, &method, url, body)? + let resp = build_admin_mutation_request(http_client, keys, &method, url, body) + .map_err(AdminMutationError::not_sent)? .send() .await .map_err(|e| crate::relay::classify_request_error(&e))?; @@ -121,7 +142,7 @@ pub(super) async fn mutation_admin_json( if resp.status() == reqwest::StatusCode::UNAUTHORIZED { // Retry once with a freshly signed request — each build mints a new // NIP-98 nonce, so this is a distinct event, not a replay of the first. - let resp2 = build_admin_mutation_request(http_client, &keys, &method, url, body)? + let resp2 = build_admin_mutation_request(http_client, keys, &method, url, body)? .send() .await .map_err(|e| crate::relay::classify_request_error(&e))?; diff --git a/desktop/src-tauri/src/commands/admin/mod.rs b/desktop/src-tauri/src/commands/admin/mod.rs index 2f7b591d4b..d63b777d56 100644 --- a/desktop/src-tauri/src/commands/admin/mod.rs +++ b/desktop/src-tauri/src/commands/admin/mod.rs @@ -655,7 +655,7 @@ pub async fn admin_put_operator( ) -> Result { let origin = origin::AdminOrigin::parse(&origin)?; let pubkey = - routes::HexPubkey::parse(&pubkey).map_err(|e| format!("invalid operator pubkey: {e}"))?; + routes::Hex64::parse(&pubkey).map_err(|e| format!("invalid operator pubkey: {e}"))?; let url = origin.route_url( &routes::AdminRoute::OperatorPut { pubkey }, &routes::AdminQuery::default(), @@ -677,7 +677,7 @@ pub async fn admin_delete_operator( ) -> Result { let origin = origin::AdminOrigin::parse(&origin)?; let pubkey = - routes::HexPubkey::parse(&pubkey).map_err(|e| format!("invalid operator pubkey: {e}"))?; + routes::Hex64::parse(&pubkey).map_err(|e| format!("invalid operator pubkey: {e}"))?; let url = origin.route_url( &routes::AdminRoute::OperatorDelete { pubkey }, &routes::AdminQuery::default(), @@ -825,7 +825,7 @@ pub async fn admin_lift_ban( state: tauri::State<'_, crate::app_state::AppState>, ) -> Result<(), AdminMutationError> { let pubkey = - routes::HexPubkey::parse(&pubkey).map_err(|e| format!("invalid member pubkey: {e}"))?; + routes::Hex64::parse(&pubkey).map_err(|e| format!("invalid member pubkey: {e}"))?; let url = restrictions_url( &origin, &routes::AdminRoute::MemberBanDelete { pubkey }, @@ -850,7 +850,7 @@ pub async fn admin_lift_timeout( state: tauri::State<'_, crate::app_state::AppState>, ) -> Result<(), AdminMutationError> { let pubkey = - routes::HexPubkey::parse(&pubkey).map_err(|e| format!("invalid member pubkey: {e}"))?; + routes::Hex64::parse(&pubkey).map_err(|e| format!("invalid member pubkey: {e}"))?; let url = restrictions_url( &origin, &routes::AdminRoute::MemberTimeoutDelete { pubkey }, @@ -862,6 +862,125 @@ pub async fn admin_lift_timeout( Ok(()) } +// ── Direct moderation actions ───────────────────────────────────────────── + +/// A direct ban, timeout, or delete as the confirm dialog froze it. The same +/// intent (including `request_id`) is resent verbatim on every retry; only the +/// NIP-98 signature is minted fresh per attempt. +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminDirectIntent { + pub origin: String, + /// Relay the intent was confirmed against. + pub expected_relay: String, + /// Signer the intent was confirmed under. + pub expected_pubkey: String, + /// Typed community host; sent only as the `communityHost` query parameter. + pub community_host: String, + pub action: DirectAction, + /// Member pubkey for ban/timeout, event id for delete (64 lowercase hex). + pub target: String, + pub request_id: uuid::Uuid, + pub reason: Option, + pub expiration_secs: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DirectAction { + Ban, + Timeout, + Delete, +} + +/// Build the URL and body for a direct action, refusing it before any request +/// when the active relay or signer no longer matches what was confirmed. +fn direct_action_request( + intent: &AdminDirectIntent, + relay_base: &str, + signer_hex: &str, +) -> Result<(String, Vec), String> { + let origin = origin::AdminOrigin::parse(&intent.origin)?; + if intent.expected_relay.trim().is_empty() + || crate::relay::assert_expected_relay_scope(Some(&intent.expected_relay), relay_base) + .is_err() + { + return Err( + "active relay changed since the action was confirmed; nothing was sent".to_string(), + ); + } + if intent.expected_pubkey.trim().is_empty() + || crate::relay::assert_expected_signer(Some(&intent.expected_pubkey), signer_hex).is_err() + { + return Err( + "active identity changed since the action was confirmed; nothing was sent".to_string(), + ); + } + let host = buzz_core_pkg::tenant::validate_community_host(intent.community_host.trim()) + .map_err(|e| format!("invalid community host: {e}"))?; + let target = + routes::Hex64::parse(&intent.target).map_err(|e| format!("invalid target: {e}"))?; + let route = match intent.action { + DirectAction::Ban => routes::AdminRoute::MemberBan { pubkey: target }, + DirectAction::Timeout => routes::AdminRoute::MemberTimeout { pubkey: target }, + DirectAction::Delete => routes::AdminRoute::EventDelete { id: target }, + }; + let expiration_secs = match (intent.action, intent.expiration_secs) { + (DirectAction::Timeout, Some(secs)) if secs > 0 => Some(secs), + (DirectAction::Timeout, _) => return Err("timeout needs a positive duration".to_string()), + (_, Some(_)) => return Err("only a timeout takes a duration".to_string()), + (_, None) => None, + }; + let mut body = serde_json::json!({ "requestId": intent.request_id }); + if let Some(reason) = &intent.reason { + body["reason"] = reason.clone().into(); + } + if let Some(secs) = expiration_secs { + body["expirationSecs"] = secs.into(); + } + let q = routes::AdminQuery { + community_host: Some(host), + ..Default::default() + }; + let body = serde_json::to_vec(&body).map_err(|e| format!("failed to serialise body: {e}"))?; + Ok((origin.route_url(&route, &q), body)) +} + +/// Ban, time out, or delete without a report — POST +/// /api/admin/v1/{members/{pubkey}/ban|members/{pubkey}/timeout|events/{id}/delete}?communityHost=. +/// +/// Returns the relay's `{actionId, state, replayed}` (200) or `{state:"pending"}` (202). +#[tauri::command] +pub async fn admin_direct_action( + intent: AdminDirectIntent, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let keys = state.signing_keys().map_err(AdminMutationError::not_sent)?; + send_direct_action(&intent, keys, &state).await +} + +/// Validate `intent` against the `keys` snapshot and sign every request with +/// that same snapshot, so an identity swapped in after the snapshot can never +/// sign an action confirmed under the previous one. +async fn send_direct_action( + intent: &AdminDirectIntent, + keys: nostr::Keys, + state: &crate::app_state::AppState, +) -> Result { + let relay_base = crate::relay::relay_api_base_url_with_override(state); + let (url, body) = direct_action_request(intent, &relay_base, &keys.public_key().to_hex()) + .map_err(AdminMutationError::not_sent)?; + let bytes = helpers::send_admin_mutation( + &keys, + reqwest::Method::POST, + &url, + Some(&body), + SUCCESS_JSON_CAP, + ) + .await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}").into()) +} + // ── Origin storage commands ─────────────────────────────────────────────── /// Core storage logic for `get_admin_origin`, parameterised by data directory diff --git a/desktop/src-tauri/src/commands/admin/mod_tests.rs b/desktop/src-tauri/src/commands/admin/mod_tests.rs index d6dd3df706..3e5c86c374 100644 --- a/desktop/src-tauri/src/commands/admin/mod_tests.rs +++ b/desktop/src-tauri/src/commands/admin/mod_tests.rs @@ -1319,7 +1319,7 @@ async fn save_rejects_an_unrelated_content_type() { #[test] fn restrictions_url_names_the_active_relay_authority() { let state = relay_state("wss://Community.Example.com:8443/ws"); - let pubkey = routes::HexPubkey::parse(&"ab".repeat(32)).unwrap(); + let pubkey = routes::Hex64::parse(&"ab".repeat(32)).unwrap(); let url = restrictions_url( "https://admin.example.com", &routes::AdminRoute::MemberBanDelete { pubkey }, @@ -1372,7 +1372,7 @@ fn restrictions_url_rejects_a_caller_relay_that_no_longer_matches() { // The list loaded from relay A; the native relay has since switched to B. // Every restriction route must fail before building a request URL. let state = relay_state("wss://relay-b.example.com"); - let pubkey = routes::HexPubkey::parse(&"ab".repeat(32)).unwrap(); + let pubkey = routes::Hex64::parse(&"ab".repeat(32)).unwrap(); for route in [ routes::AdminRoute::MemberRestrictionsList, routes::AdminRoute::MemberBanDelete { @@ -1387,3 +1387,6 @@ fn restrictions_url_rejects_a_caller_relay_that_no_longer_matches() { } } } + +#[path = "direct_action_tests.rs"] +mod direct_action; diff --git a/desktop/src-tauri/src/commands/admin/routes.rs b/desktop/src-tauri/src/commands/admin/routes.rs index e61a827ebf..30e2ef8ba7 100644 --- a/desktop/src-tauri/src/commands/admin/routes.rs +++ b/desktop/src-tauri/src/commands/admin/routes.rs @@ -72,40 +72,54 @@ pub enum AdminRoute { }, OperatorsList, OperatorPut { - pubkey: HexPubkey, + pubkey: Hex64, }, OperatorDelete { - pubkey: HexPubkey, + pubkey: Hex64, }, /// GET /members/restrictions — communityHost in query. MemberRestrictionsList, /// DELETE /members/{pubkey}/ban — communityHost in query. MemberBanDelete { - pubkey: HexPubkey, + pubkey: Hex64, }, /// DELETE /members/{pubkey}/timeout — communityHost in query. MemberTimeoutDelete { - pubkey: HexPubkey, + pubkey: Hex64, + }, + /// POST /members/{pubkey}/ban — communityHost in query. + MemberBan { + pubkey: Hex64, + }, + /// POST /members/{pubkey}/timeout — communityHost in query. + MemberTimeout { + pubkey: Hex64, + }, + /// POST /events/{id}/delete — communityHost in query. An event id has the + /// same 64 lowercase-hex grammar as a pubkey. + EventDelete { + id: Hex64, }, } -/// A validated 64 lowercase-hex character pubkey for use as a URL path segment. +/// A validated 64 lowercase-hex id (a pubkey or an event id) for use as a URL +/// path segment. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct HexPubkey(String); +pub struct Hex64(String); -impl HexPubkey { - /// Parse `raw` as a 64-character lowercase hex pubkey. +impl Hex64 { + /// Parse `raw` as a 64-character lowercase hex id. pub fn parse(raw: &str) -> Result { if raw.len() != 64 { return Err(format!( - "pubkey must be exactly 64 hex characters; got {}", + "id must be exactly 64 hex characters; got {}", raw.len() )); } if !raw.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) { - return Err("pubkey must be lowercase hex only (0-9, a-f)".to_string()); + return Err("id must be lowercase hex only (0-9, a-f)".to_string()); } - Ok(HexPubkey(raw.to_string())) + Ok(Hex64(raw.to_string())) } pub fn as_str(&self) -> &str { @@ -139,6 +153,11 @@ impl AdminRoute { AdminRoute::MemberTimeoutDelete { pubkey } => { format!("/members/{}/timeout", pubkey.as_str()) } + AdminRoute::MemberBan { pubkey } => format!("/members/{}/ban", pubkey.as_str()), + AdminRoute::MemberTimeout { pubkey } => { + format!("/members/{}/timeout", pubkey.as_str()) + } + AdminRoute::EventDelete { id } => format!("/events/{}/delete", id.as_str()), } } } @@ -428,7 +447,7 @@ mod tests { #[test] fn member_ban_delete_path() { - let pubkey = HexPubkey::parse(&"ab".repeat(32)).unwrap(); + let pubkey = Hex64::parse(&"ab".repeat(32)).unwrap(); assert_eq!( AdminRoute::MemberBanDelete { pubkey }.path(), format!("/members/{}/ban", "ab".repeat(32)) @@ -437,10 +456,28 @@ mod tests { #[test] fn member_timeout_delete_path() { - let pubkey = HexPubkey::parse(&"cd".repeat(32)).unwrap(); + let pubkey = Hex64::parse(&"cd".repeat(32)).unwrap(); assert_eq!( AdminRoute::MemberTimeoutDelete { pubkey }.path(), format!("/members/{}/timeout", "cd".repeat(32)) ); } + + #[test] + fn direct_action_paths() { + let hex = || Hex64::parse(&"ef".repeat(32)).unwrap(); + let h = "ef".repeat(32); + assert_eq!( + AdminRoute::MemberBan { pubkey: hex() }.path(), + format!("/members/{h}/ban") + ); + assert_eq!( + AdminRoute::MemberTimeout { pubkey: hex() }.path(), + format!("/members/{h}/timeout") + ); + assert_eq!( + AdminRoute::EventDelete { id: hex() }.path(), + format!("/events/{h}/delete") + ); + } } diff --git a/desktop/src-tauri/src/egress_guard.rs b/desktop/src-tauri/src/egress_guard.rs index db58ddafa0..1d990477f6 100644 --- a/desktop/src-tauri/src/egress_guard.rs +++ b/desktop/src-tauri/src/egress_guard.rs @@ -14,6 +14,7 @@ //! | 6 | `submit_engram_event` (team snapshot) | `commands/team_snapshot.rs` | //! | 7 | `submit_engram_event` (persona import) | `commands/personas/snapshot/import.rs` | //! | 8 | native websocket send loop (all webview relay WS) | `native_websocket.rs` | +//! | 9 | `send_admin_mutation` (all admin API JSON bodies) | `commands/admin/helpers.rs` | //! //! The inventory-completeness test in `egress_guard_tests.rs` asserts that //! every `/events` URL-construction site in the tree calls this guard, so a diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index b6035d2ca3..4e2c5c4132 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -293,6 +293,13 @@ const EVENTS_INVENTORY: &[(&str, usize, usize)] = &[ // Stub-relay route in the tombstone-flush gate tests; production flush // publishes through the guarded boundary-1 funnel. ("src/commands/teams/pending/tests/gate.rs", 1, 0), + // Admin API mutations: every JSON body passes the guard in + // `helpers::send_admin_mutation` (injection tests in admin `direct_action_tests.rs`). + // The `/events/{id}/delete` route + its path test and the native delete + // test only construct URLs that flow into that guarded boundary. + ("src/commands/admin/helpers.rs", 0, 1), + ("src/commands/admin/routes.rs", 2, 0), + ("src/commands/admin/direct_action_tests.rs", 1, 0), ]; // Needles are assembled at runtime so this scan file itself contains no @@ -457,6 +464,8 @@ fn ncryptsec_handling_is_confined_to_allowlisted_files() { "src/commands/team_snapshot/tests.rs", "src/commands/personas/snapshot/import.rs", "src/native_websocket.rs", + "src/commands/admin/helpers.rs", + "src/commands/admin/direct_action_tests.rs", ]; let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index a37d9c536a..bd062960e0 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -898,6 +898,7 @@ pub fn run() { admin_list_restrictions, admin_lift_ban, admin_lift_timeout, + admin_direct_action, get_admin_origin, set_admin_origin, admin_discover_origin, diff --git a/desktop/src/features/admin-console/AdminConsoleActionsTab.tsx b/desktop/src/features/admin-console/AdminConsoleActionsTab.tsx new file mode 100644 index 0000000000..4bd32d1883 --- /dev/null +++ b/desktop/src/features/admin-console/AdminConsoleActionsTab.tsx @@ -0,0 +1,580 @@ +/** + * Actions tab — ban, time out, or delete without a report. + * + * Review freezes the whole intent (origin, relay, signer, community, verb, + * target, reason, duration, requestId). Confirm and every retry resend that + * frozen intent, so the relay dedupes on the same requestId. Review and + * Confirm share one in-flight lock, so a late Review can never replace an + * intent that is frozen or being sent. The native layer + * mints a fresh NIP-98 signature per attempt and refuses the send if the + * active relay or signer moved. The panel remounts this tab on an identity or + * origin change, which drops any frozen intent. + * + * The community defaults to the active relay's host (read-only, with a + * Change escape hatch), and members are picked by name on that relay or by a + * pasted npub/hex key anywhere. + */ + +import { useDeferredValue, useRef, useState } from "react"; +import { toast } from "sonner"; +import { classifyKeyImportInput } from "@/features/onboarding/lib/keyImportInput"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { + useUserProfileQuery, + useUserSearchQuery, +} from "@/features/profile/hooks"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { SelectedRecipientChip } from "@/features/profile/ui/SelectedRecipientChip"; +import { getRelayWsUrl } from "@/shared/api/tauri"; +import type { UserSearchResult } from "@/shared/api/types"; +import { parsePubkeyInput } from "@/shared/lib/nostrUtils"; +import { truncateNpub } from "@/shared/lib/pubkey"; +import { Button } from "@/shared/ui/button"; +import { PubKey } from "@/shared/ui/PubKey"; +import { + directAdminAction, + type AdminDirectAction, + type AdminDirectIntent, +} from "./api"; +import { + adminErrorCode, + adminErrorMessage, + adminMutationBodyComplete, + adminMutationNotSent, + adminMutationRelayStatus, + preserveRequestIdOnError, + useAsyncLoad, +} from "./AdminConsolePanelHelpers"; +import { reasonAudienceCopy } from "./AdminConsoleReportsTab"; + +const HEX64 = /^[0-9a-f]{64}$/; + +const ACTION_LABELS: Record = { + ban: "Ban member", + timeout: "Time out member", + delete: "Delete message", +}; + +function directErrorMessage(e: unknown): string { + switch (adminErrorCode(e)) { + case "target_is_staff": + return "Relay staff can't be banned or timed out. Remove their staff role first."; + case "request_id_conflict": + return "This request id was already used for a different action. Review again to send it with a new id."; + case null: { + // A relay from before direct actions answers these routes with an empty + // 404/405; any other uncoded body keeps its own text. + const status = adminMutationRelayStatus(e); + if ( + (status === 404 || status === 405) && + adminMutationBodyComplete(e) && + adminErrorMessage(e).trim() === "admin API error:" + ) { + return "This relay doesn't support direct actions yet."; + } + return adminErrorMessage(e); + } + default: + return adminErrorMessage(e); + } +} + +/** + * Normalize a community host the way the relay's `normalize_host` does: + * lowercase, no default port, no trailing root dot. + */ +function normalizeCommunityHost(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/:(443|80)$/, "") + .replace(/\.$/, ""); +} + +/** + * The community a relay URL serves is its `Host`: the URL's authority, with + * the default port already dropped for `ws`/`wss`. Null when unparseable. + */ +export function communityHostFromRelayUrl(relayUrl: string): string | null { + try { + return normalizeCommunityHost(new URL(relayUrl).host) || null; + } catch { + return null; + } +} + +/** An `nsec` or NIP-49 backup, in either case. */ +function isSecretKey(input: string): boolean { + return classifyKeyImportInput(input.toLowerCase()) !== "unknown"; +} + +function memberLabel(user: UserSearchResult): string { + return ( + user.displayName?.trim() || + user.nip05Handle?.trim() || + truncateNpub(user.pubkey) + ); +} + +/** Client-side checks; the relay re-validates everything. */ +function validate( + action: AdminDirectAction, + host: string, + target: string, + member: UserSearchResult | null, + secs: string, +): string | null { + if (!host.trim()) return "Enter the community host."; + if (action === "delete" && !HEX64.test(target.trim())) { + return "Event id must be 64 lowercase hex characters."; + } + if (action !== "delete" && !member) { + return "Choose a member: search by name, or paste an npub or hex key."; + } + if ( + action === "timeout" && + !(Number.isInteger(Number(secs)) && Number(secs) > 0) + ) { + return "Duration must be a whole number of seconds above zero."; + } + return null; +} + +export function ActionsTab({ + canMutate, + origin, + pubkey, +}: { + canMutate: boolean; + origin: string; + /** Active signer; frozen into the intent at review. */ + pubkey: string; +}) { + const [action, setAction] = useState("ban"); + const { activeCommunity } = useCommunities(); + /** Operator-typed host; null while following the active community. */ + const [hostOverride, setHostOverride] = useState(null); + const [target, setTarget] = useState(""); + const [member, setMember] = useState(null); + /** Display-only name for the frozen target; never sent. */ + const [frozenName, setFrozenName] = useState(null); + const [reason, setReason] = useState(""); + const [secs, setSecs] = useState(""); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + const [frozen, setFrozen] = useState(null); + const [submitting, setSubmitting] = useState(false); + const inFlight = useRef(false); + + // Host of the active relay: the default target community. Reloads when the + // desktop switches communities. + const activeHostState = useAsyncLoad( + async () => communityHostFromRelayUrl(await getRelayWsUrl()), + [activeCommunity?.relayUrl ?? ""], + 0, + ); + const activeHost = + activeHostState.status === "ok" ? activeHostState.data : null; + + const editingHost = hostOverride !== null || !activeHost; + const host = hostOverride ?? activeHost ?? ""; + // Name search queries the active relay, so it only finds members there. + const normalizedHost = normalizeCommunityHost(host); + const onActiveCommunity = + activeHost !== null && normalizedHost === activeHost; + // Profiles are per community: a new host drops the picked member. + const editHost = (next: string) => { + if (normalizeCommunityHost(next) !== normalizedHost) setMember(null); + setHostOverride(next); + }; + + const handleReview = async () => { + if (frozen || inFlight.current) return; + const invalid = validate(action, host, target, member, secs); + setError(invalid); + if (invalid) return; + inFlight.current = true; + setSubmitting(true); + try { + const expectedRelay = await getRelayWsUrl(); + // Following the active community: derive the host from the very relay + // URL being frozen, so the two can never disagree. + const communityHost = + hostOverride?.trim() ?? communityHostFromRelayUrl(expectedRelay); + if (!communityHost) { + setError("Enter the community host."); + return; + } + const isMember = action !== "delete" && member; + setFrozenName( + isMember && (member.displayName || member.nip05Handle) + ? memberLabel(member) + : null, + ); + setFrozen({ + origin, + expectedRelay, + expectedPubkey: pubkey, + communityHost, + action, + target: isMember ? member.pubkey : target.trim(), + requestId: crypto.randomUUID(), + reason: reason.trim() || undefined, + expirationSecs: action === "timeout" ? Number(secs) : undefined, + }); + } catch (e) { + setError(adminErrorMessage(e)); + } finally { + inFlight.current = false; + setSubmitting(false); + } + }; + + const handleConfirm = async () => { + if (!frozen || inFlight.current) return; + inFlight.current = true; + setSubmitting(true); + setError(null); + setPending(false); + try { + const result = await directAdminAction(frozen); + if (result.state === "pending") { + setPending(true); + } else { + toast.success(`${ACTION_LABELS[frozen.action]}: done`); + setFrozen(null); + setTarget(""); + setMember(null); + setReason(""); + } + } catch (e) { + // Keep the frozen intent (same requestId) unless the relay definitively + // rejected it before committing. A request-id conflict is final for + // this id, and a refusal before sending (bad host, relay or signer + // moved) repeats on every resend, so neither can ever succeed. + if ( + !preserveRequestIdOnError(e) || + adminMutationNotSent(e) || + adminErrorCode(e) === "request_id_conflict" + ) { + setFrozen(null); + } + setError(directErrorMessage(e)); + } finally { + inFlight.current = false; + setSubmitting(false); + } + }; + + const locked = frozen !== null || submitting || !canMutate; + // A pasted key has no name yet; look it up where search would find it. + const confirmProfile = useUserProfileQuery( + frozen && + frozen.action !== "delete" && + !frozenName && + frozen.communityHost === activeHost + ? frozen.target + : undefined, + ); + const confirmName = + frozenName ?? confirmProfile.data?.displayName?.trim() ?? null; + const input = ( + name: string, + value: string, + set: (v: string) => void, + placeholder: string, + extra = "", + type = "text", + ) => ( + set(e.target.value)} + placeholder={placeholder} + type={type} + value={value} + /> + ); + + return ( +
+
+ {(Object.keys(ACTION_LABELS) as AdminDirectAction[]).map((a) => ( + + ))} +
+ {editingHost ? ( + input("host", host, editHost, "Community host (e.g. team.example.com)") + ) : ( +

+ + Community: {host} + + +

+ )} + {action === "delete" ? ( + input("target", target, setTarget, "Event id (hex)", "font-mono") + ) : ( + + )} + {action === "timeout" && + input("duration", secs, setSecs, "Duration (seconds)", "", "number")} + {input("reason", reason, setReason, "Reason (optional)")} +

+ {reasonAudienceCopy(frozen?.action ?? action)} +

+ {pending && ( +

+ Accepted; the relay is still applying it. Retry to check. +

+ )} + {error && ( +

+ {error} +

+ )} + {frozen ? ( +
+

+ {ACTION_LABELS[frozen.action]}{" "} + {frozen.action === "delete" ? ( + {frozen.target} + ) : ( + + {confirmName ? `${confirmName} ` : ""} + + {confirmName + ? `(${truncateNpub(frozen.target)})` + : truncateNpub(frozen.target)} + + + )}{" "} + in {frozen.communityHost} + {frozen.expirationSecs ? ` for ${frozen.expirationSecs}s` : ""}? +

+ {frozen.action !== "delete" && ( + + )} +

+ Reason: {frozen.reason ?? "(none)"} +

+
+ + +
+
+ ) : ( + + )} +
+ ); +} + +/** + * Pick a ban/timeout target: search names on the active relay, or paste an + * npub or hex key. Mirrors the Add Member dialog's picker. + */ +function MemberPicker({ + disabled, + member, + onChange, + searchEnabled, +}: { + disabled: boolean; + member: UserSearchResult | null; + onChange: (member: UserSearchResult | null) => void; + /** Off when the target community is not the active relay's. */ + searchEnabled: boolean; +}) { + const [query, setQuery] = useState(""); + const [inspecting, setInspecting] = useState(false); + const deferred = useDeferredValue(query.trim()); + const parsed = parsePubkeyInput(deferred); + // A pasted secret key must never leave the device as a search query. + const secret = isSecretKey(query) || isSecretKey(deferred); + const search = useUserSearchQuery(deferred, { + enabled: searchEnabled && !secret && deferred.length > 0 && parsed === null, + limit: 8, + }); + const results: UserSearchResult[] = parsed + ? [ + { + pubkey: parsed, + displayName: null, + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + isAgent: false, + }, + ] + : searchEnabled && !secret + ? (search.data ?? []) + : []; + + const hint = !searchEnabled && ( +

+ Name search only works in the community you're connected to. +

+ ); + if (member) { + return ( +
+ onChange(null)} + poofOnRemove={false} + testIds={{ + chip: "direct-member-remove", + name: "direct-member-inspect", + pubkey: "direct-member-npub", + }} + user={member} + /> + {hint} +
+ ); + } + return ( +
+ setQuery(e.target.value)} + placeholder={ + searchEnabled + ? "Search by name, or paste an npub or hex key" + : "npub or hex key" + } + value={query} + /> + {secret && ( +

+ That's a secret key. Never paste it here. +

+ )} + {hint} + {results.length > 0 && ( +
+ {results.map((user) => ( + + ))} +
+ )} +
+ ); +} diff --git a/desktop/src/features/admin-console/AdminConsolePanel.tsx b/desktop/src/features/admin-console/AdminConsolePanel.tsx index 0af4facfe3..9d868308f4 100644 --- a/desktop/src/features/admin-console/AdminConsolePanel.tsx +++ b/desktop/src/features/admin-console/AdminConsolePanel.tsx @@ -2,9 +2,9 @@ * Main admin console panel — renders when probe state is `nip98Authorized` or * `disabled`. * - * Shows three tabs: Reports (deployment-wide moderation reports), Feedback - * (product feedback with optional image attachments), and Staffing (Operator- - * only operator management). + * Shows four tabs: Reports (deployment-wide moderation reports), Feedback + * (product feedback with optional image attachments), Actions (direct ban, + * timeout, and delete), and Staffing (Operator-only operator management). * * All query/UI state is keyed by `(pubkey, origin)`. In-flight native requests * are fenced by an effect-local `active` flag that is set to `false` in the @@ -23,12 +23,13 @@ */ import { useEffect, useRef, useState } from "react"; -import { MessageSquare, ShieldAlert, Users } from "lucide-react"; +import { Gavel, MessageSquare, ShieldAlert, Users } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import type { AdminPrincipalRole } from "./api"; import { ReportsTab } from "./AdminConsoleReportsTab"; import { FeedbackTab } from "./AdminConsoleFeedbackTab"; import { StaffingTab } from "./AdminConsoleStaffingTab"; +import { ActionsTab } from "./AdminConsoleActionsTab"; export { parseImetaAttachments, @@ -37,7 +38,7 @@ export { // ── Tab bar ─────────────────────────────────────────────────────────────── -type Tab = "reports" | "feedback" | "staffing"; +type Tab = "reports" | "feedback" | "actions" | "staffing"; function TabBar({ activeTab, @@ -55,6 +56,7 @@ function TabBar({ }> = [ { value: "reports", label: "Reports", Icon: ShieldAlert }, { value: "feedback", label: "Feedback", Icon: MessageSquare }, + { value: "actions", label: "Actions", Icon: Gavel }, ...(showStaffing ? [{ value: "staffing" as const, label: "Staffing", Icon: Users }] : []), @@ -140,6 +142,7 @@ export function AdminConsolePanel({ const visibleTabs = new Set([ "reports", "feedback", + "actions", ...(isOperator ? (["staffing"] as Tab[]) : []), ]); if (!visibleTabs.has(activeTab)) { @@ -175,6 +178,16 @@ export function AdminConsolePanel({ generation={generation} /> )} + {/* Stays mounted across tab switches so a frozen, pending or in-flight + action (and its requestId) survives a trip to another tab. */} + {activeTab === "staffing" && isOperator && ( c.querySelector(`[data-testid='${id}']`); + +async function mountActions({ canMutate = true } = {}) { + const panel = mountPanel({ + origin: CM_ORIGIN, + pubkey: CM_PUBKEY, + canMutate, + initialTab: "actions", + }); + await panel.doRender(); + await settle(); + return panel; +} + +async function type(c, id, value) { + await act(async () => { + fireEvent.change(q(c, id), { target: { value } }); + }); +} + +async function click(c, id) { + await act(async () => { + fireEvent.click(q(c, id)); + }); + await settle(); +} + +async function setHost(c, host) { + if (q(c, "direct-host-change")) await click(c, "direct-host-change"); + await type(c, "direct-host-input", host); +} + +/** Paste a key and pick its direct result. */ +async function pickKey(c, key, hex = TARGET) { + await type(c, "direct-member-input", key); + await settle(); + assert.ok(q(c, `direct-member-result-${hex}`), `no direct result for ${key}`); + await click(c, `direct-member-result-${hex}`); +} + +async function fillTimeout(c) { + await click(c, "direct-action-timeout"); + await setHost(c, " team.example.com "); + await pickKey(c, TARGET); + await type(c, "direct-duration-input", "60"); + await type(c, "direct-reason-input", "spam"); +} + +test("actions-validation: bad target or duration shows an error and sends nothing", async () => { + let calls = 0; + setIpcHandler("admin_direct_action", () => { + calls += 1; + return Promise.resolve({}); + }); + const { container: c, unmount } = await mountActions(); + try { + await click(c, "direct-review-btn"); + assert.match(q(c, "direct-error").textContent, /Choose a member/); + await setHost(c, ""); + await click(c, "direct-review-btn"); + assert.match(q(c, "direct-error").textContent, /community host/); + await type(c, "direct-host-input", "team.example.com"); + await click(c, "direct-action-delete"); + await type(c, "direct-target-input", TARGET.toUpperCase()); + await click(c, "direct-review-btn"); + assert.match(q(c, "direct-error").textContent, /64 lowercase hex/); + await click(c, "direct-action-timeout"); + await pickKey(c, TARGET); + await type(c, "direct-duration-input", "0"); + await click(c, "direct-review-btn"); + assert.match(q(c, "direct-error").textContent, /Duration/); + assert.ok(!q(c, "direct-confirm"), "direct-confirm must be absent"); + assert.equal(calls, 0); + } finally { + await unmount(); + } +}); + +test("actions-success: confirm sends the frozen intent with signer, relay, and typed host", async () => { + const sent = []; + setIpcHandler("admin_direct_action", ({ intent }) => { + sent.push(intent); + return Promise.resolve({ + actionId: "a1", + state: "succeeded", + replayed: false, + }); + }); + const { container: c, unmount } = await mountActions(); + try { + await fillTimeout(c); + await click(c, "direct-review-btn"); + assert.equal(sent.length, 0, "review alone must not send"); + await click(c, "direct-confirm-btn"); + assert.equal(sent.length, 1); + const { requestId, ...rest } = sent[0]; + assert.match(requestId, /^[0-9a-f-]{36}$/); + assert.deepEqual(rest, { + origin: CM_ORIGIN, + expectedRelay: TEST_RELAY_WS_URL, + expectedPubkey: CM_PUBKEY, + communityHost: "team.example.com", + action: "timeout", + target: TARGET, + reason: "spam", + expirationSecs: 60, + }); + assert.deepEqual(capturedToasts, ["Time out member: done"]); + assert.ok(!q(c, "direct-confirm"), "direct-confirm must be absent"); + } finally { + await unmount(); + } +}); + +test("actions-retry: an ambiguous failure keeps the intent and a manual retry reuses the requestId", async () => { + // Mutation: mint requestId in handleConfirm, or drop preserveRequestIdOnError → RED. + const ids = []; + const replies = [ + () => mutationReject("network down", null), + () => + mutationReject( + 'admin API error: {"error":{"code":"internal","message":"boom"}}', + 500, + ), + () => + Promise.resolve({ actionId: "a1", state: "succeeded", replayed: true }), + ]; + setIpcHandler("admin_direct_action", ({ intent }) => { + ids.push(intent.requestId); + return replies[ids.length - 1](); + }); + const { container: c, unmount } = await mountActions(); + try { + await fillTimeout(c); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + assert.equal(q(c, "direct-confirm-btn").textContent, "Retry"); + assert.ok( + q(c, "direct-member-remove").disabled, + "fields stay locked to the frozen intent", + ); + await click(c, "direct-confirm-btn"); + await click(c, "direct-confirm-btn"); + assert.equal(ids.length, 3); + assert.equal( + new Set(ids).size, + 1, + "every retry carries the same requestId", + ); + } finally { + await unmount(); + } +}); + +test("actions-errors: relay codes map to copy; a definitive 4xx unlocks the form", async () => { + const cases = [ + ["target_is_staff", 409, /Relay staff can't be banned/, true], + ["request_id_conflict", 409, /already used for a different action/, false], + ["event_not_in_community", 404, /not in that community/, false], + ["unknown_community_host", 400, /unknown host/, false], + ["enforcement_failed", 422, /enforcement broke/, false], + ]; + for (const [code, status, copy, keepsIntent] of cases) { + const message = copy.source.replace(/\\/g, ""); + setIpcHandler("admin_direct_action", () => + mutationReject( + `admin API error: {"error":{"code":"${code}","message":"${message}"}}`, + status, + ), + ); + const { container: c, unmount } = await mountActions(); + try { + await fillTimeout(c); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + assert.match(q(c, "direct-error").textContent, copy, code); + assert.equal(Boolean(q(c, "direct-confirm")), keepsIntent, code); + } finally { + await unmount(); + } + } +}); + +test("actions-pending-neutral: a 202 renders as a neutral notice, not an error", async () => { + setIpcHandler("admin_direct_action", () => + Promise.resolve({ state: "pending" }), + ); + const { container: c, unmount } = await mountActions(); + try { + await fillTimeout(c); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + const notice = q(c, "direct-pending"); + assert.ok(notice, "pending notice must render"); + assert.ok(!notice.className.includes("text-destructive")); + assert.ok(!q(c, "direct-error"), "pending must not render as an error"); + assert.equal(q(c, "direct-confirm-btn").textContent, "Retry"); + } finally { + await unmount(); + } +}); + +test("actions-request-id-conflict: a 409 conflict drops the intent and offers no Retry", async () => { + setIpcHandler("admin_direct_action", () => + mutationReject( + 'admin API error: {"error":{"code":"request_id_conflict","message":"conflict"}}', + 409, + ), + ); + const { container: c, unmount } = await mountActions(); + try { + await fillTimeout(c); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + assert.match( + q(c, "direct-error").textContent, + /Review again to send it with a new id/, + ); + assert.ok(!q(c, "direct-confirm-btn"), "no Retry for a spent request id"); + assert.ok(q(c, "direct-review-btn"), "the form is back to Review"); + } finally { + await unmount(); + } +}); + +test("actions-pending: a 202 keeps the intent for a same-id retry", async () => { + setIpcHandler("admin_direct_action", () => + Promise.resolve({ state: "pending" }), + ); + const { container: c, unmount } = await mountActions(); + try { + await fillTimeout(c); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + assert.match(q(c, "direct-pending").textContent, /still applying/); + assert.ok(q(c, "direct-confirm")); + assert.deepEqual(capturedToasts, []); + } finally { + await unmount(); + } +}); + +test("actions-identity: a signer change remounts the tab and drops the frozen intent", async () => { + // Mutation: remove the ActionsTab key → frozen intent survives → RED. + let calls = 0; + setIpcHandler("admin_direct_action", () => { + calls += 1; + return Promise.resolve({ + actionId: "a1", + state: "succeeded", + replayed: false, + }); + }); + const { container: c, doRender, unmount } = await mountActions(); + try { + await fillTimeout(c); + await click(c, "direct-review-btn"); + assert.ok(q(c, "direct-confirm")); + await doRender({ origin: CM_ORIGIN, pubkey: "ee".repeat(32) }); + await settle(); + assert.ok(!q(c, "direct-confirm"), "direct-confirm must be absent"); + assert.equal(q(c, "direct-member-input").value, ""); + assert.equal(calls, 0); + } finally { + await unmount(); + } +}); + +test("actions-disabled-auth: canMutate=false keeps Review and every field off", async () => { + // Mutation: drop !canMutate from the Review/lock gates → RED. + const { container: c, unmount } = await mountActions({ canMutate: false }); + try { + assert.ok(q(c, "direct-review-btn").disabled); + assert.ok(q(c, "direct-member-input").disabled); + assert.ok(q(c, "direct-action-ban").disabled); + } finally { + await unmount(); + } +}); + +test("actions-review-race: a late second Review never replaces the submitted intent", async () => { + // Mutation: drop the in-flight guard at the top of handleReview → RED + // (the second Review re-freezes with a new requestId and Retry sends it). + const relays = []; + setIpcHandler( + "get_relay_ws_url", + () => + new Promise((resolve) => relays.push(() => resolve(TEST_RELAY_WS_URL))), + ); + const ids = []; + const replies = [ + () => mutationReject("network down", null), + () => + Promise.resolve({ actionId: "a1", state: "succeeded", replayed: true }), + ]; + setIpcHandler("admin_direct_action", ({ intent }) => { + ids.push(intent.requestId); + return replies[ids.length - 1](); + }); + const mounted = mountActions(); + await settle(); + // The tab reads the active relay once on mount to prefill the host. + await act(async () => { + for (const resolve of relays.splice(0)) resolve(); + }); + const { container: c, unmount } = await mounted; + try { + await fillTimeout(c); + await act(async () => { + fireEvent.click(q(c, "direct-review-btn")); + fireEvent.click(q(c, "direct-review-btn")); + }); + const pending = relays.splice(0); + await act(async () => pending[0]()); + await settle(); + await click(c, "direct-confirm-btn"); + await act(async () => { + for (const resolve of pending.slice(1)) resolve(); + }); + await settle(); + await click(c, "direct-confirm-btn"); + assert.equal(ids.length, 2); + assert.equal(ids[0], ids[1], "retry must replay the submitted requestId"); + } finally { + await unmount(); + } +}); + +test("actions-audience: the reason's recipients are disclosed and the frozen reason is shown", async () => { + // Mutation: remove the direct-reason-audience line or the confirm reason → RED. + const { container: c, unmount } = await mountActions(); + try { + const audience = () => q(c, "direct-reason-audience").textContent; + assert.equal(audience(), "Sent verbatim to the affected user."); + await click(c, "direct-action-delete"); + assert.equal( + audience(), + "Sent verbatim to the affected user and posted publicly in the room.", + ); + await fillTimeout(c); + await click(c, "direct-review-btn"); + assert.equal(audience(), "Sent verbatim to the affected user."); + assert.equal(q(c, "direct-confirm-reason").textContent, "Reason: spam"); + } finally { + await unmount(); + } +}); + +test("actions-host-prefill: the host follows the active relay read-only, and Change reveals the input", async () => { + // Mutation: drop the active-relay prefill (activeHost stays null) → RED. + const sent = []; + setIpcHandler("admin_direct_action", ({ intent }) => { + sent.push(intent); + return Promise.resolve({ + actionId: "a1", + state: "succeeded", + replayed: false, + }); + }); + const { container: c, unmount } = await mountActions(); + try { + assert.ok( + q(c, "direct-host"), + "host must be prefilled from the active relay", + ); + assert.match(q(c, "direct-host").textContent, /Community: relay\.test/); + assert.ok(!q(c, "direct-host-input"), "host input hidden until Change"); + await pickKey(c, TARGET); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + assert.equal(sent[0].communityHost, "relay.test"); + await click(c, "direct-host-change"); + assert.equal(q(c, "direct-host-input").value, "relay.test"); + } finally { + await unmount(); + } +}); + +test("actions-member-search: a name result is sent as hex and named on the confirm step", async () => { + setIpcHandler("search_users", ({ query }) => + Promise.resolve({ + users: query.startsWith("ali") + ? [ + { + pubkey: TARGET, + display_name: "Alice", + avatar_url: null, + nip05_handle: null, + owner_pubkey: null, + }, + ] + : [], + next_cursor: null, + }), + ); + const sent = []; + setIpcHandler("admin_direct_action", ({ intent }) => { + sent.push(intent); + return Promise.resolve({ + actionId: "a1", + state: "succeeded", + replayed: false, + }); + }); + const { container: c, unmount } = await mountActions(); + try { + await type(c, "direct-member-input", "ali"); + await settle(); + await click(c, `direct-member-result-${TARGET}`); + await click(c, "direct-review-btn"); + assert.match(q(c, "direct-confirm-member").textContent, /^Alice \(npub1/); + await click(c, "direct-confirm-btn"); + assert.equal(sent[0].target, TARGET); + } finally { + await unmount(); + } +}); + +test("actions-member-keys: an npub and uppercase hex are both sent as lowercase hex", async () => { + // Mutation: accept only lowercase hex instead of parsePubkeyInput → RED. + const sent = []; + setIpcHandler("admin_direct_action", ({ intent }) => { + sent.push(intent); + return Promise.resolve({ + actionId: "a1", + state: "succeeded", + replayed: false, + }); + }); + for (const key of [pubkeyToNpub(TARGET), TARGET.toUpperCase()]) { + const { container: c, unmount } = await mountActions(); + try { + await pickKey(c, key); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + } finally { + await unmount(); + } + } + assert.deepEqual( + sent.map((i) => i.target), + [TARGET, TARGET], + ); +}); + +test("actions-foreign-host: another community's host turns name search off and says why", async () => { + let searches = 0; + setIpcHandler("search_users", () => { + searches += 1; + return Promise.resolve({ users: [], next_cursor: null }); + }); + const { container: c, unmount } = await mountActions(); + try { + assert.ok( + !q(c, "direct-member-search-hint"), + "no hint on the active community", + ); + await setHost(c, "other.example.com"); + assert.match( + q(c, "direct-member-search-hint").textContent, + /only works in the community you're connected to/, + ); + await type(c, "direct-member-input", "alice"); + await settle(); + assert.equal(searches, 0, "no name search against another community"); + await pickKey(c, TARGET); + assert.ok(q(c, "direct-member-selected"), "keys still work"); + } finally { + await unmount(); + } +}); + +const OTHER = "cd".repeat(32); + +function searchReturns(users) { + setIpcHandler("search_users", () => + Promise.resolve({ + users: users.map(([pubkey, name]) => ({ + pubkey, + display_name: name, + avatar_url: null, + nip05_handle: null, + owner_pubkey: null, + })), + next_cursor: null, + }), + ); +} + +test("actions-host-change-drops-member: a name picked in one community can't be reviewed in another", async () => { + // Mutation: mask the pick by host instead of clearing it → RED on A→B→A. + searchReturns([[TARGET, "Alice"]]); + const { container: c, unmount } = await mountActions(); + try { + await type(c, "direct-member-input", "ali"); + await settle(); + await click(c, `direct-member-result-${TARGET}`); + await click(c, "direct-host-change"); + assert.ok(q(c, "direct-member-selected"), "Change alone keeps the pick"); + await setHost(c, "other.example.com"); + assert.ok( + !q(c, "direct-member-selected"), + "the pick must not survive a host change", + ); + assert.equal(q(c, "direct-member-input").value, "", "query is cleared"); + await click(c, "direct-review-btn"); + assert.ok(!q(c, "direct-confirm"), "nothing to review"); + assert.match(q(c, "direct-error").textContent, /Choose a member/); + await setHost(c, "relay.test"); + assert.ok( + !q(c, "direct-member-selected"), + "returning to the first host must not restore the old pick", + ); + await click(c, "direct-review-btn"); + assert.ok(!q(c, "direct-confirm"), "still nothing to review"); + assert.match(q(c, "direct-error").textContent, /Choose a member/); + await setHost(c, "other.example.com"); + await pickKey(c, TARGET); + assert.ok( + q(c, "direct-member-search-hint"), + "hint stays visible with a key selected", + ); + await click(c, "direct-review-btn"); + assert.doesNotMatch(q(c, "direct-confirm-member").textContent, /Alice/); + } finally { + await unmount(); + } +}); + +test("actions-same-name: two same-name results are told apart and the chosen full key is confirmed", async () => { + // Mutation: drop the full PubKey from the confirm step → RED. + searchReturns([ + [TARGET, "Alice"], + [OTHER, "Alice"], + ]); + const sent = []; + setIpcHandler("admin_direct_action", ({ intent }) => { + sent.push(intent); + return Promise.resolve({ + actionId: "a1", + state: "succeeded", + replayed: false, + }); + }); + const { container: c, unmount } = await mountActions(); + try { + await type(c, "direct-member-input", "ali"); + await settle(); + const a = q(c, `direct-member-result-${TARGET}`).textContent; + const b = q(c, `direct-member-result-${OTHER}`).textContent; + assert.notEqual(a, b, "same-name results must differ by key"); + await click(c, `direct-member-result-${OTHER}`); + assert.ok(q(c, "direct-member-inspect"), "selected chip is inspectable"); + await click(c, "direct-review-btn"); + assert.ok(q(c, "direct-confirm-npub"), "confirm must show the full npub"); + assert.match( + q(c, "direct-confirm-npub").textContent, + new RegExp(pubkeyToNpub(OTHER)), + "confirm shows the chosen member's full npub", + ); + await click(c, "direct-confirm-btn"); + assert.equal(sent[0].target, OTHER); + } finally { + await unmount(); + } +}); + +test("actions-results-disabled: results showing when auth drops can't be selected", async () => { + searchReturns([[TARGET, "Alice"]]); + const panel = await mountActions(); + const c = panel.container; + try { + await type(c, "direct-member-input", "ali"); + await settle(); + await panel.doRender({ canMutate: false }); + await settle(); + const result = q(c, `direct-member-result-${TARGET}`); + assert.ok(result, "results still shown"); + assert.ok(result.disabled, "results must be disabled"); + await click(c, `direct-member-result-${TARGET}`); + assert.ok(!q(c, "direct-member-selected")); + } finally { + await panel.unmount(); + } +}); + +test("actions-secret-key: a pasted secret key or key backup is never searched and is flagged", async () => { + // Mutation: drop the isSecretKey gate from the picker's search → RED. + // The backup prefix is assembled so this file stays outside the frontend + // key-backup source scan's allowlist; it is only ever typed into the picker. + const backup = ["ncrypt", "sec1"].join(""); + const queries = []; + setIpcHandler("search_users", ({ query }) => { + queries.push(query); + return Promise.resolve({ users: [], next_cursor: null }); + }); + const { container: c, unmount } = await mountActions(); + try { + for (const key of [ + `nsec1${"q".repeat(58)}`, + `NSEC1${"Q".repeat(58)}`, + `${backup}${"q".repeat(40)}`, + `${backup.toUpperCase()}${"Q".repeat(40)}`, + ]) { + await type(c, "direct-member-input", key); + await settle(); + assert.match( + q(c, "direct-member-secret")?.textContent ?? "", + /secret key/, + `no warning for ${key.slice(0, 10)}`, + ); + } + assert.deepEqual(queries, [], "a secret key reached search_users"); + await type(c, "direct-member-input", ""); + assert.ok(!q(c, "direct-member-secret"), "clearing drops the warning"); + await type(c, "direct-member-input", "alice"); + await settle(); + assert.deepEqual(queries, ["alice"], "name search still runs"); + await pickKey(c, pubkeyToNpub(TARGET)); + assert.ok(q(c, "direct-member-selected"), "npub still works"); + } finally { + await unmount(); + } +}); + +async function toTab(c, tab) { + await click(c, `admin-tab-${tab}`); +} + +test("actions-tab-roundtrip-pending: a 202 survives a tab switch and Retry reuses the requestId", async () => { + // Mutation: render ActionsTab only while it is the active tab → RED. + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + const ids = []; + setIpcHandler("admin_direct_action", ({ intent }) => { + ids.push(intent.requestId); + return Promise.resolve({ state: "pending" }); + }); + const { container: c, unmount } = await mountActions(); + try { + await fillTimeout(c); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + await toTab(c, "feedback"); + await toTab(c, "actions"); + assert.ok(q(c, "direct-pending"), "pending notice survives"); + await click(c, "direct-confirm-btn"); + assert.equal(ids.length, 2); + assert.equal(ids[0], ids[1], "Retry after a tab trip reuses the id"); + } finally { + await unmount(); + } +}); + +test("actions-tab-roundtrip-ambiguous: an ambiguous failure survives a tab switch", async () => { + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + const ids = []; + setIpcHandler("admin_direct_action", ({ intent }) => { + ids.push(intent.requestId); + return mutationReject("network down", null); + }); + const { container: c, unmount } = await mountActions(); + try { + await fillTimeout(c); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + await toTab(c, "feedback"); + await toTab(c, "actions"); + assert.equal(q(c, "direct-confirm-btn")?.textContent, "Retry"); + await click(c, "direct-confirm-btn"); + assert.equal(ids.length, 2); + assert.equal(ids[0], ids[1]); + } finally { + await unmount(); + } +}); + +test("actions-tab-roundtrip-inflight: a send that fails on another tab shows its error on return", async () => { + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + let fail; + setIpcHandler( + "admin_direct_action", + () => + new Promise((_, reject) => { + fail = () => reject({ message: "network down", relayStatus: null }); + }), + ); + const { container: c, unmount } = await mountActions(); + try { + await fillTimeout(c); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + await toTab(c, "feedback"); + await act(async () => fail()); + await settle(); + await toTab(c, "actions"); + assert.match(q(c, "direct-error")?.textContent ?? "", /network down/); + assert.equal(q(c, "direct-confirm-btn")?.textContent, "Retry"); + } finally { + await unmount(); + } +}); + +test("actions-not-sent: a refusal before sending drops the intent and offers no Retry", async () => { + // Mutation: drop the adminMutationNotSent check from handleConfirm → RED. + let calls = 0; + setIpcHandler("admin_direct_action", () => { + calls += 1; + return Promise.reject({ + message: "invalid community host: path not allowed", + relayStatus: null, + bodyComplete: false, + notSent: true, + }); + }); + const { container: c, unmount } = await mountActions(); + try { + await setHost(c, "team.example.com/"); + await pickKey(c, TARGET); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + assert.equal(calls, 1); + assert.ok(!q(c, "direct-confirm"), "intent dropped; no Retry"); + assert.match(q(c, "direct-error").textContent, /invalid community host/); + assert.ok(q(c, "direct-review-btn"), "back to Review"); + } finally { + await unmount(); + } +}); + +test("actions-old-relay: only an empty 404 or 405 says the relay lacks direct actions", async () => { + // Mutation: drop the empty-body condition from directErrorMessage → RED. + for (const [status, message, expected] of [ + [ + 404, + "admin API error: ", + "This relay doesn't support direct actions yet.", + ], + [ + 405, + "admin API error: ", + "This relay doesn't support direct actions yet.", + ], + [404, "admin API error: Not Found", "admin API error: Not Found"], + [ + 405, + "admin API error: Method Not Allowed", + "admin API error: Method Not Allowed", + ], + ]) { + setIpcHandler("admin_direct_action", () => mutationReject(message, status)); + const { container: c, unmount } = await mountActions(); + try { + await pickKey(c, TARGET); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + assert.equal( + q(c, "direct-error").textContent, + expected, + `${status} ${message}`, + ); + } finally { + await unmount(); + } + } +}); + +test("actions-backup-reason-not-sent: a key-backup reason refusal returns to editable Review", async () => { + // Mirrors the native backup-guard refusal (Rust asserts notSent there). + // Mutation: return notSent: false, as before the fix → RED. + const backup = "see " + "ncrypt" + "sec1qgg9947"; + let calls = 0; + setIpcHandler("admin_direct_action", () => { + calls += 1; + return Promise.reject({ + message: "admin API mutation: refused to send key-backup material", + relayStatus: null, + bodyComplete: false, + notSent: true, + }); + }); + const { container: c, unmount } = await mountActions(); + try { + await pickKey(c, TARGET); + await type(c, "direct-reason-input", backup); + await click(c, "direct-review-btn"); + await click(c, "direct-confirm-btn"); + assert.equal(calls, 1); + assert.ok(!q(c, "direct-confirm"), "no Retry after a pre-send refusal"); + assert.ok(q(c, "direct-review-btn"), "back to Review"); + assert.equal( + q(c, "direct-reason-input").disabled, + false, + "Reason is editable again", + ); + } finally { + await unmount(); + } +}); diff --git a/desktop/src/features/admin-console/adminConsolePanelTestHelpers.jsdom.mjs b/desktop/src/features/admin-console/adminConsolePanelTestHelpers.jsdom.mjs index e16b23f99d..61b761798e 100644 --- a/desktop/src/features/admin-console/adminConsolePanelTestHelpers.jsdom.mjs +++ b/desktop/src/features/admin-console/adminConsolePanelTestHelpers.jsdom.mjs @@ -193,7 +193,11 @@ export function mountPanel({ const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); - const doRender = async ({ origin: o, pubkey: p } = { origin, pubkey }) => { + const doRender = async ({ + origin: o = origin, + pubkey: p = pubkey, + canMutate: m = canMutate, + } = {}) => { await act(async () => { root.render( React.createElement( @@ -203,7 +207,7 @@ export function mountPanel({ CommunitiesProvider, null, React.createElement(AdminConsolePanel, { - canMutate, + canMutate: m, origin: o, pubkey: p, ...(role !== undefined ? { role } : {}), diff --git a/desktop/src/features/admin-console/api.ts b/desktop/src/features/admin-console/api.ts index 4845c4543c..684b77ba12 100644 --- a/desktop/src/features/admin-console/api.ts +++ b/desktop/src/features/admin-console/api.ts @@ -696,3 +696,39 @@ export async function saveAdminAttachment( expectedSize, }); } + +// ── Direct moderation actions ───────────────────────────────────────────── + +export type AdminDirectAction = "ban" | "timeout" | "delete"; + +/** A direct action as confirmed; resent verbatim on every retry. */ +export type AdminDirectIntent = { + origin: string; + expectedRelay: string; + expectedPubkey: string; + communityHost: string; + action: AdminDirectAction; + /** Member pubkey (ban/timeout) or event id (delete), 64 lowercase hex. */ + target: string; + requestId: string; + reason?: string; + expirationSecs?: number; +}; + +export type AdminDirectActionResult = + | { actionId: string; state: "succeeded"; replayed: boolean } + | { state: "pending" }; + +/** + * Ban, time out, or delete without a report. The native command refuses the + * intent before sending if the active relay or signer changed since confirm. + * + * POST /api/admin/v1/{members/{pubkey}/ban|members/{pubkey}/timeout|events/{id}/delete}?communityHost= + */ +export async function directAdminAction( + intent: AdminDirectIntent, +): Promise { + return invokeTauri("admin_direct_action", { + intent, + }); +}