diff --git a/desktop/package.json b/desktop/package.json index 3601f25185e..dd1210e3591 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -14,7 +14,7 @@ "lint": "biome lint .", "check": "biome check . && pnpm check:file-sizes && pnpm check:px-text && pnpm check:pubkey-truncation", "format": "biome format --write .", - "test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\"", + "test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\" && node --import ./test-jsdom-setup.mjs --import ./test-loader.mjs --experimental-strip-types --test-force-exit --test \"src/**/*.jsdom-test.mjs\"", "preview": "vite preview", "tauri": "tauri", "test:e2e": "pnpm build:e2e && playwright test", diff --git a/desktop/scripts/check-pubkey-truncation.mjs b/desktop/scripts/check-pubkey-truncation.mjs index d65db135454..061024b1303 100644 --- a/desktop/scripts/check-pubkey-truncation.mjs +++ b/desktop/scripts/check-pubkey-truncation.mjs @@ -28,6 +28,8 @@ const overrides = new Set([ "src/features/messages/lib/threadPanel.ts:395", "src/features/projects/ui/ProjectsView.tsx:166", "src/features/projects/ui/ProjectsOverviewPanel.tsx:209", + // Error message prefix in a console-internal action error (never rendered as identity). + "src/features/admin-console/AdminConsoleStaffingTab.tsx:108", ]); await runPubkeyTruncationCheck({ diff --git a/desktop/src-tauri/src/commands/admin/client.rs b/desktop/src-tauri/src/commands/admin/client.rs new file mode 100644 index 00000000000..21e805776ad --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/client.rs @@ -0,0 +1,100 @@ +//! Dedicated no-redirect HTTP client for admin API requests. +//! +//! A separate client (not the app-wide `http_client`) ensures that: +//! - 3xx responses are surfaced as errors rather than followed — preventing +//! redirect-hop SSRF where a relay-issued redirect could forward the NIP-98 +//! `Authorization` header to an off-origin host. +//! - Timeouts are tuned for synchronous UI feedback rather than media downloads. + +use std::sync::OnceLock; + +/// Request timeout for admin API calls. +pub(crate) const ADMIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +/// The module-level singleton admin HTTP client. +/// +/// Built once via `OnceLock` — panics on build failure so there is no +/// silent fallback to a redirect-following client. +pub static ADMIN_CLIENT: OnceLock = OnceLock::new(); + +/// Initialise the admin client singleton. Must be called from `setup()` before +/// any admin command can be invoked. Subsequent calls are no-ops. +pub fn init_admin_client() { + ADMIN_CLIENT.get_or_init(|| { + reqwest::Client::builder() + .resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0))) + .pool_idle_timeout(std::time::Duration::from_secs(10)) + .pool_max_idle_per_host(2) + .redirect(reqwest::redirect::Policy::none()) + .timeout(ADMIN_TIMEOUT) + .build() + .expect( + "admin HTTP client must build with redirect::Policy::none(); \ + a redirect-following fallback would forward the NIP-98 \ + Authorization header across origins (redirect-hop SSRF)", + ) + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The admin client must be buildable and must refuse to follow redirects. + /// This mirrors the `build_media_fetch_client_succeeds_with_no_redirect_policy` + /// test in `media_download.rs`. + #[test] + fn admin_client_builds_with_no_redirect_policy() { + init_admin_client(); + assert!(ADMIN_CLIENT.get().is_some()); + } + + /// A live test that the client does not follow a 302. + /// + /// Mirrors `media_fetch_client_does_not_follow_redirects` in + /// `media_download.rs`. Serves a 302 pointing at the metadata endpoint + /// and asserts exactly one connection was accepted. + #[tokio::test] + async fn admin_client_does_not_follow_redirects() { + use std::io::{Read, Write}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + init_admin_client(); + let client = ADMIN_CLIENT.get().expect("client initialised"); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let connections = Arc::new(AtomicUsize::new(0)); + + let server_connections = Arc::clone(&connections); + let server = std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + server_connections.fetch_add(1, Ordering::SeqCst); + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf); + let response = "HTTP/1.1 302 Found\r\n\ + Location: http://169.254.169.254/latest/meta-data/\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\r\n"; + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } + }); + + let resp = client + .get(format!("http://{addr}/api/admin/v1/reports")) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await + .expect("request should complete without following the redirect"); + + assert_eq!(resp.status().as_u16(), 302); + server.join().unwrap(); + assert_eq!( + connections.load(Ordering::SeqCst), + 1, + "exactly one request must be issued — redirect must not be followed", + ); + } +} diff --git a/desktop/src-tauri/src/commands/admin/error.rs b/desktop/src-tauri/src/commands/admin/error.rs new file mode 100644 index 00000000000..75e3f3a370f --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/error.rs @@ -0,0 +1,69 @@ +//! Typed error for admin mutation commands. + +/// Error from an admin mutation command, carrying whether the relay +/// authoritatively answered so the UI can decide idempotency-retry policy +/// without string-matching the message. +/// +/// `relayStatus` is `Some(code)` only when the relay returned an HTTP status — +/// the request reached the relay and it answered. It is `None` for a +/// pre-response transport failure (`send()` error, DNS/connect/timeout) or a +/// pre-send failure (auth build, body serialisation): the relay never +/// committed anything, so a retry must reuse the same idempotency key. +/// +/// `bodyComplete` is `true` only when the relay's full response body was read — +/// an authoritative verdict. A non-409 4xx with `bodyComplete: true` is a +/// definitive pre-commit rejection, so the UI may mint a fresh idempotency key. +/// A status that arrives but whose body is lost mid-stream (or rejected over +/// the size cap) carries `bodyComplete: false`: the outcome is unknown, so the +/// caller preserves idempotency and lets the retry dedupe even on a 4xx. +/// +/// Serialises `rename_all = "camelCase"`; the JS bridge surfaces it as the +/// rejected `TauriInvokeError.payload`, from which the UI reads `relayStatus` +/// and `bodyComplete`. +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminMutationError { + /// Human-readable message — byte-identical to the string the command + /// produced before typing, so existing message parsing is unaffected. + pub message: String, + /// The relay's HTTP status when a response was received; `None` for a + /// transport/pre-send failure where no relay answer exists. + pub relay_status: Option, + /// 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, +} + +impl AdminMutationError { + /// 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 { + Self { + message, + relay_status: Some(status.as_u16()), + body_complete: true, + } + } + + /// The relay answered with an HTTP status but the body was not fully read + /// (redirect, over-cap, or a mid-stream read failure) — outcome unknown. + pub(super) fn partial(status: reqwest::StatusCode, message: String) -> Self { + Self { + message, + relay_status: Some(status.as_u16()), + body_complete: false, + } + } +} + +/// Pre-send and transport failures carry no relay status: the relay never saw +/// the request (or never answered), so the outcome is unambiguously "no commit". +impl From for AdminMutationError { + fn from(message: String) -> Self { + Self { + message, + relay_status: None, + body_complete: false, + } + } +} diff --git a/desktop/src-tauri/src/commands/admin/helpers.rs b/desktop/src-tauri/src/commands/admin/helpers.rs new file mode 100644 index 00000000000..db498aa05be --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/helpers.rs @@ -0,0 +1,345 @@ +//! HTTP helpers for the desktop admin surface. +//! +//! NIP-98 authenticated fetch/mutation wrappers and response-reading utilities +//! used by the Tauri command implementations in `mod.rs`. + +use super::client; +use super::{AdminMutationError, ATTACHMENT_CAP, ERROR_BODY_CAP}; + +/// Fetch a JSON endpoint with NIP-98 auth, one 401-retry, and a size cap. +pub(super) async fn fetch_admin_json( + url: &str, + cap: u64, + state: &tauri::State<'_, crate::app_state::AppState>, +) -> Result, String> { + use crate::relay::build_nip98_auth_header_for_keys; + + let keys = state.signing_keys()?; + let http_client = client::ADMIN_CLIENT + .get() + .ok_or_else(|| "admin client not initialised".to_string())?; + + let auth_header = build_nip98_auth_header_for_keys(&keys, &reqwest::Method::GET, url, &[]) + .map_err(|e| format!("nip98 build failed: {e}"))?; + + let resp = http_client + .get(url) + .header(reqwest::header::AUTHORIZATION, &auth_header) + .send() + .await + .map_err(|e| crate::relay::classify_request_error(&e))?; + + // One retry on 401 with a fresh NIP-98 event (new nonce). + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + let auth_header2 = build_nip98_auth_header_for_keys(&keys, &reqwest::Method::GET, url, &[]) + .map_err(|e| format!("nip98 build failed on retry: {e}"))?; + let resp2 = http_client + .get(url) + .header(reqwest::header::AUTHORIZATION, auth_header2) + .send() + .await + .map_err(|e| crate::relay::classify_request_error(&e))?; + return read_admin_response(resp2, cap, ERROR_BODY_CAP).await; + } + + read_admin_response(resp, cap, ERROR_BODY_CAP).await +} + +/// POST a JSON body with NIP-98 auth (payload sha256 in the tag), one 401-retry, size cap. +pub(super) async fn post_admin_json( + url: &str, + body: &[u8], + cap: u64, + state: &tauri::State<'_, crate::app_state::AppState>, +) -> Result, AdminMutationError> { + mutation_admin_json(reqwest::Method::POST, url, body, cap, state).await +} + +/// PATCH a JSON body with NIP-98 auth (payload sha256), one 401-retry, size cap. +pub(super) async fn patch_admin_json( + url: &str, + body: &[u8], + cap: u64, + state: &tauri::State<'_, crate::app_state::AppState>, +) -> Result, AdminMutationError> { + mutation_admin_json(reqwest::Method::PATCH, url, body, cap, state).await +} + +/// PUT a JSON body with NIP-98 auth (payload sha256), one 401-retry, size cap. +pub(super) async fn put_admin_json( + url: &str, + body: &[u8], + cap: u64, + state: &tauri::State<'_, crate::app_state::AppState>, +) -> Result, AdminMutationError> { + mutation_admin_json(reqwest::Method::PUT, url, body, cap, state).await +} + +/// DELETE with NIP-98 auth (no body), one 401-retry, size cap. +pub(super) async fn delete_admin_json( + url: &str, + cap: u64, + state: &tauri::State<'_, crate::app_state::AppState>, +) -> Result, String> { + use crate::relay::build_nip98_auth_header_for_keys; + + let keys = state.signing_keys()?; + let http_client = client::ADMIN_CLIENT + .get() + .ok_or_else(|| "admin client not initialised".to_string())?; + + let auth_header = build_nip98_auth_header_for_keys(&keys, &reqwest::Method::DELETE, url, &[]) + .map_err(|e| format!("nip98 build failed: {e}"))?; + + let resp = http_client + .delete(url) + .header(reqwest::header::AUTHORIZATION, &auth_header) + .send() + .await + .map_err(|e| crate::relay::classify_request_error(&e))?; + + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + let auth_header2 = + build_nip98_auth_header_for_keys(&keys, &reqwest::Method::DELETE, url, &[]) + .map_err(|e| format!("nip98 build failed on retry: {e}"))?; + let resp2 = http_client + .delete(url) + .header(reqwest::header::AUTHORIZATION, auth_header2) + .send() + .await + .map_err(|e| crate::relay::classify_request_error(&e))?; + return read_admin_response(resp2, cap, ERROR_BODY_CAP).await; + } + + read_admin_response(resp, cap, ERROR_BODY_CAP).await +} + +/// Shared implementation for POST/PATCH/PUT with NIP-98 payload sha256 binding. +/// +/// Returns a typed [`AdminMutationError`] so the caller can distinguish a +/// relay-authoritative failure (a status was received) from a transport or +/// pre-send failure (no relay answer). `?` on the `String`-producing steps +/// (auth build, `send()` classification) converts via `From` to a +/// no-status error, which is correct: none of those reached a relay verdict. +pub(super) async fn mutation_admin_json( + method: reqwest::Method, + url: &str, + body: &[u8], + cap: u64, + state: &tauri::State<'_, crate::app_state::AppState>, +) -> Result, AdminMutationError> { + use crate::relay::build_nip98_auth_header_for_keys; + + let keys = state.signing_keys()?; + let http_client = client::ADMIN_CLIENT + .get() + .ok_or_else(|| "admin client not initialised".to_string())?; + + // NIP-98 §4: for body-bearing requests, include a `payload` tag over the + // SHA-256 of the exact request body bytes. + let auth_header = build_nip98_auth_header_for_keys(&keys, &method, url, body) + .map_err(|e| format!("nip98 build failed: {e}"))?; + + let send_request = |auth: String| { + http_client + .request(method.clone(), url) + .header(reqwest::header::AUTHORIZATION, auth) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body.to_vec()) + .send() + }; + + let resp = send_request(auth_header) + .await + .map_err(|e| crate::relay::classify_request_error(&e))?; + + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + let auth_header2 = build_nip98_auth_header_for_keys(&keys, &method, url, body) + .map_err(|e| format!("nip98 build failed on retry: {e}"))?; + let resp2 = send_request(auth_header2) + .await + .map_err(|e| crate::relay::classify_request_error(&e))?; + return read_admin_mutation_response(resp2, cap, ERROR_BODY_CAP).await; + } + + read_admin_mutation_response(resp, cap, ERROR_BODY_CAP).await +} + +/// Stream and validate an attachment response, enforcing Content-Type, size, +/// and the cap. +pub(super) async fn finish_attachment_response( + resp: reqwest::Response, + expected_mime: &str, + expected_size: u64, +) -> Result { + use futures_util::StreamExt; + + if resp.status().is_redirection() { + return Err("admin_attachment_redirect".to_string()); + } + if !resp.status().is_success() { + return Err(format!( + "admin_attachment_relay_error_{}", + resp.status().as_u16() + )); + } + + // Verify Content-Type before reading the body. + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .split(';') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + if content_type != expected_mime.trim().to_ascii_lowercase() { + return Err("admin_attachment_mime_mismatch".to_string()); + } + + // Content-Length preflight. + if let Some(cl) = resp.content_length() { + if cl > ATTACHMENT_CAP { + return Err("admin_attachment_too_large".to_string()); + } + if cl != expected_size { + return Err("admin_attachment_size_mismatch".to_string()); + } + } + + // Stream with running byte counter. + let mut bytes: Vec = Vec::new(); + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| "admin_attachment_stream_error".to_string())?; + if bytes.len() as u64 + chunk.len() as u64 > ATTACHMENT_CAP { + return Err("admin_attachment_too_large".to_string()); + } + bytes.extend_from_slice(&chunk); + } + + // Final size check. + if bytes.len() as u64 != expected_size { + return Err("admin_attachment_size_mismatch".to_string()); + } + + Ok(tauri::ipc::Response::new(bytes)) +} + +/// Read a response body up to `success_cap` bytes on 2xx, `error_cap` on +/// non-2xx. Redirects are treated as errors (the no-redirect client surfaced +/// them rather than following). +pub(super) async fn read_admin_response( + resp: reqwest::Response, + success_cap: u64, + error_cap: u64, +) -> Result, String> { + use futures_util::StreamExt; + + if resp.status().is_redirection() { + return Err(format!( + "admin API returned a {} redirect (not followed)", + resp.status() + )); + } + + let (is_success, cap) = if resp.status().is_success() { + (true, success_cap) + } else { + (false, error_cap) + }; + + if let Some(cl) = resp.content_length() { + if cl > cap { + return Err(format!( + "admin response too large ({cl} bytes, cap {cap} bytes)" + )); + } + } + + let mut bytes: Vec = Vec::new(); + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("admin response stream error: {e}"))?; + if bytes.len() as u64 + chunk.len() as u64 > cap { + return Err(format!("admin response too large (cap {cap} bytes)")); + } + bytes.extend_from_slice(&chunk); + } + + if !is_success { + let body = String::from_utf8_lossy(&bytes); + return Err(format!("admin API error: {body}")); + } + + Ok(bytes) +} + +/// Read a mutation response, preserving the relay's HTTP status in the error. +/// +/// Mirrors [`read_admin_response`]'s size discipline and message wording so the +/// UI's message parsing is unchanged, but on a non-2xx it returns an +/// [`AdminMutationError`] tagged with the received status and whether the full +/// body was read. Only a status with a complete body (`authoritative`) is a +/// verdict the UI treats as definitive; a redirect, an over-cap body, or a +/// mid-stream read failure carries the status as `partial` — the relay answered +/// but the outcome is unknown, so the caller preserves the idempotency key and +/// lets the retry dedupe against any commit that landed. +async fn read_admin_mutation_response( + resp: reqwest::Response, + success_cap: u64, + error_cap: u64, +) -> Result, AdminMutationError> { + use futures_util::StreamExt; + + let status = resp.status(); + + if status.is_redirection() { + return Err(AdminMutationError::partial( + status, + format!("admin API returned a {status} redirect (not followed)"), + )); + } + + let (is_success, cap) = if status.is_success() { + (true, success_cap) + } else { + (false, error_cap) + }; + + if let Some(cl) = resp.content_length() { + if cl > cap { + return Err(AdminMutationError::partial( + status, + format!("admin response too large ({cl} bytes, cap {cap} bytes)"), + )); + } + } + + let mut bytes: Vec = Vec::new(); + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| { + AdminMutationError::partial(status, format!("admin response stream error: {e}")) + })?; + if bytes.len() as u64 + chunk.len() as u64 > cap { + return Err(AdminMutationError::partial( + status, + format!("admin response too large (cap {cap} bytes)"), + )); + } + bytes.extend_from_slice(&chunk); + } + + if !is_success { + let body = String::from_utf8_lossy(&bytes); + return Err(AdminMutationError::authoritative( + status, + format!("admin API error: {body}"), + )); + } + + Ok(bytes) +} diff --git a/desktop/src-tauri/src/commands/admin/mod.rs b/desktop/src-tauri/src/commands/admin/mod.rs new file mode 100644 index 00000000000..8927ae5ce0f --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/mod.rs @@ -0,0 +1,999 @@ +//! Desktop in-app admin surface — NIP-98 client for `/api/admin/v1`. +//! +//! Implements five Tauri commands that fetch JSON and binary content from the +//! relay's deployment-admin API using the app keypair as the NIP-98 signing +//! identity. A sixth command, `admin_probe`, discovers which authentication +//! mode the configured admin origin is running and whether the app identity +//! is authorized. +//! +//! # Security model +//! +//! The webview never supplies paths, methods, or full URLs. Every IPC command +//! accepts an `AdminOrigin` (scheme + host + optional port, validated on +//! construction) and typed query parameters; the final URL is built natively +//! from a closed route enum. The URL that is signed is byte-identical to the +//! URL that is fetched. +//! +//! A dedicated no-redirect reqwest client prevents redirect-hop SSRF — a relay +//! 3xx is returned verbatim and treated as an error so the NIP-98 header is +//! never forwarded across origins. +//! +//! Keys are acquired via `AppState::signing_keys()`, which returns `Err` when +//! the identity is in recovery mode (keyring locked or lost), ensuring the app +//! keypair can never sign admin events under an inaccessible identity. +//! +//! Response sizes are bounded by Content-Length preflight and a streaming byte +//! counter, mirroring the `media_download.rs` pattern. + +pub mod client; +pub(super) mod helpers; +pub(crate) mod origin; +pub(crate) mod routes; + +// ── Response size caps ──────────────────────────────────────────────────── + +/// Success-JSON cap: reports list returns up to 200 rows, each note field +/// can reach the 256 KiB event-content cap. Sized for the worst case. +const SUCCESS_JSON_CAP: u64 = 52_428_800; // 50 MiB + +/// Probe-response cap: `/probe` returns a tiny fixed-shape JSON envelope. +/// 8 KiB is far more than the payload needs while bounding a hostile body. +const PROBE_JSON_CAP: u64 = 8_192; // 8 KiB + +/// Error-body cap: relay error responses are brief JSON envelopes. +const ERROR_BODY_CAP: u64 = 65_536; // 64 KiB + +/// Attachment preview cap. 10 MiB is generous for images and small documents +/// while protecting against accidental OOM. +const ATTACHMENT_CAP: u64 = 10_485_760; // 10 MiB + +// Re-export helpers into this module's namespace. +use helpers::{ + delete_admin_json, fetch_admin_json, finish_attachment_response, patch_admin_json, + post_admin_json, put_admin_json, +}; + +// ── Typed mutation error ────────────────────────────────────────────────── + +pub(crate) mod error; +pub use error::AdminMutationError; + +// ── Typed probe result ──────────────────────────────────────────────────── + +/// Result of an `admin_probe` call. Each variant maps to a distinct UI state. +/// Tauri serialises this as `{ "state": "", ... }`. +#[derive(Debug, serde::Serialize)] +#[serde(tag = "state", rename_all = "camelCase")] +pub enum AdminProbeResult { + /// NIP-98 mode is active and the current app keypair is on the allowlist. + /// Includes the principal's `role` and `source` for the staffing tab. + Nip98Authorized { + /// The resolved role: `"operator"` or `"moderator"`. + role: Option, + /// How the role was resolved: `"config"`, `"owner_fallback"`, or `"db"`. + source: Option, + }, + /// NIP-98 mode is active but the app keypair was rejected after a signed + /// attempt. Likely: pubkey not in `RELAY_OPERATOR_PUBKEYS`, clock skew, or + /// relay config mismatch. + Nip98Denied, + /// Bearer-token mode (`BUZZ_ADMIN_AUTH=token`). The desktop cannot mint a + /// bearer token; the operator must use the web console. + TokenMode, + /// Auth is disabled (`BUZZ_ADMIN_AUTH=disabled`). No credential needed. + Disabled, + /// The origin is reachable but the `/api/admin/v1` prefix is absent or + /// returns a non-admin response. + NotAdminApi, + /// Network/TLS error, DNS failure, or Cloudflare Access interception. + NetworkOrIntercepted, +} + +// ── Typed query struct ──────────────────────────────────────────────────── + +/// Query parameters accepted by `admin_list_reports`. +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminReportsQuery { + pub community_id: Option, + pub status: Option, + pub report_type: Option, + pub target_kind: Option, + pub after: Option, + pub before: Option, + pub limit: Option, +} + +// ── Probe ───────────────────────────────────────────────────────────────── + +/// A boxed signing closure: given a URL, returns a `Nostr ` Authorization header. +type SignFn = Box Result + Send + Sync>; + +/// Probe an admin origin to determine the authentication mode and whether the +/// current app keypair is authorized. +/// +/// Algorithm: +/// 1. Send an unauthenticated GET to `/api/admin/v1/probe`. +/// 2. Detect HTML/interception pages (Cloudflare Access, captive portals) +/// from Content-Type and final URL host → `NetworkOrIntercepted`. +/// 3. 200 + valid `ProbeResponse` with `authMode: "disabled"` → `Disabled`. +/// 4. 401 + `WWW-Authenticate: Nostr` → NIP-98 mode. Retry with a freshly +/// signed kind-27235. 200 + valid `ProbeResponse` → `Nip98Authorized` +/// carrying the relay-resolved `role`/`source`; non-200 → `Nip98Denied`. +/// 5. 401 + `WWW-Authenticate: Bearer` → `TokenMode`. +/// 6. 403/404 or other non-401 → `NotAdminApi`. +/// 7. Network/redirect/TLS error → `NetworkOrIntercepted`. +#[tauri::command] +pub async fn admin_probe( + origin: String, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + use crate::relay::build_nip98_auth_header_for_keys; + + // Resolve signing keys before entering the inner probe. Recovery mode + // (locked/lost keyring) is surfaced here rather than inside the loop. + let sign: Option = match state.signing_keys() { + Ok(keys) => Some(Box::new(move |url: &str| { + build_nip98_auth_header_for_keys(&keys, &reqwest::Method::GET, url, &[]) + .map_err(|e| format!("nip98 build failed: {e}")) + })), + Err(_) => None, + }; + + admin_probe_inner(&origin, sign).await +} + +/// Inner probe implementation with injectable signing. +/// +/// Accepts an optional signing closure so live-listener tests can drive the +/// full state machine — including the Nostr challenge/response path — without +/// requiring a real `AppState`. `None` simulates recovery mode (no key). +async fn admin_probe_inner( + origin: &str, + sign: Option Result>, +) -> Result { + let origin = origin::AdminOrigin::parse(origin)?; + let url = origin.route_url(&routes::AdminRoute::Probe, &routes::AdminQuery::default()); + + let http_client = client::ADMIN_CLIENT + .get() + .ok_or_else(|| "admin client not initialised".to_string())?; + + // Step 1: unauthenticated GET. + let resp = match http_client.get(&url).send().await { + Ok(r) => r, + Err(e) => { + tracing::debug!(error = %e, "admin_probe: network error"); + return Ok(AdminProbeResult::NetworkOrIntercepted); + } + }; + + if resp.status().is_redirection() { + return Ok(AdminProbeResult::NetworkOrIntercepted); + } + + // Step 2: detect HTML/interception before reading body or interpreting status. + if is_probe_response_intercepted(&resp) { + return Ok(AdminProbeResult::NetworkOrIntercepted); + } + + // Step 3: success without auth → disabled mode (only when the body is a + // coherent disabled-mode probe: status ok, authMode disabled, no + // principal, no capabilities). A 200 in any other shape or mode is a + // contract violation (token/nip98 must 401 an unauthenticated caller); + // classify defensively. + if resp.status().is_success() { + let content_type = response_content_type(&resp); + let bytes = read_bounded(resp, PROBE_JSON_CAP).await?; + return Ok(match parse_probe(&content_type, &bytes) { + Some(p) if p.is_coherent_disabled() => AdminProbeResult::Disabled, + _ => AdminProbeResult::NotAdminApi, + }); + } + + // Step 4–6: interpret 401. + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + let www_auth = resp + .headers() + .get(reqwest::header::WWW_AUTHENTICATE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_ascii_lowercase(); + + if www_auth.starts_with("nostr") { + // NIP-98 mode: try signing. + let auth_header = match &sign { + Some(f) => f(&url)?, + None => return Ok(AdminProbeResult::Nip98Denied), + }; + let auth_resp = match http_client + .get(&url) + .header(reqwest::header::AUTHORIZATION, &auth_header) + .send() + .await + { + Ok(r) => r, + Err(_) => return Ok(AdminProbeResult::NetworkOrIntercepted), + }; + + // Redirects on the authenticated retry are also interception. + if auth_resp.status().is_redirection() { + return Ok(AdminProbeResult::NetworkOrIntercepted); + } + + // Validate the Authorization header was accepted by checking for HTML. + if is_probe_response_intercepted(&auth_resp) { + return Ok(AdminProbeResult::NetworkOrIntercepted); + } + + if auth_resp.status().is_success() { + let content_type = response_content_type(&auth_resp); + let bytes = read_bounded(auth_resp, PROBE_JSON_CAP).await?; + return Ok(match parse_probe(&content_type, &bytes) { + // Trust the 2xx only when the full NIP-98 invariant holds; + // carry the relay-resolved role/source for the staffing tab. + Some(p) => match p.authorized_principal() { + Some((role, source)) => AdminProbeResult::Nip98Authorized { + role: Some(role.as_str().to_string()), + source: Some(source.as_str().to_string()), + }, + // Structurally a probe body but not a coherent + // authorized NIP-98 response: fail closed. + None => AdminProbeResult::NotAdminApi, + }, + // 2xx but not a probe shape: endpoint exists but isn't + // the admin API. + None => AdminProbeResult::NotAdminApi, + }); + } + return Ok(AdminProbeResult::Nip98Denied); + } + + if www_auth.starts_with("bearer") { + return Ok(AdminProbeResult::TokenMode); + } + + // Unknown 401 shape. + return Ok(AdminProbeResult::NotAdminApi); + } + + Ok(AdminProbeResult::NotAdminApi) +} + +/// Check the response Content-Type and final URL host for signs of +/// captive-portal or Cloudflare Access interception. +/// +/// Uses the same classification logic as `relay.rs::classify_intercepted_response`. +fn is_probe_response_intercepted(resp: &reqwest::Response) -> bool { + let host = resp.url().host_str().unwrap_or("").to_lowercase(); + let ct = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_lowercase(); + + // Cloudflare Access redirects to its own domain. + if host == "cloudflareaccess.com" || host.ends_with(".cloudflareaccess.com") { + return true; + } + // Any HTML body from a non-relay host is a proxy/captive portal page. + if ct.contains("text/html") { + return true; + } + false +} + +/// Read a bounded response body (no auth check, just bytes). +async fn read_bounded(resp: reqwest::Response, cap: u64) -> Result, String> { + use futures_util::StreamExt; + + if let Some(cl) = resp.content_length() { + if cl > cap { + return Err(format!("probe response too large ({cl} bytes)")); + } + } + let mut bytes = Vec::new(); + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("probe stream error: {e}"))?; + if bytes.len() as u64 + chunk.len() as u64 > cap { + return Err(format!("probe response too large (cap {cap} bytes)")); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +/// The relay's `/probe` response contract (`ProbeResponse` in the relay's +/// `api/admin/mod.rs`, serialised `rename_all = "camelCase"`). +/// +/// All six fields are required and typed; deserialisation rejects a body that +/// omits any field or carries a wrong-typed one, so an unrelated JSON endpoint +/// cannot be mistaken for the admin API. Unknown fields are tolerated for +/// forward compatibility. Structural validity alone does NOT authorize: a +/// deserialised `ProbeWire` still has to pass [`ProbeWire::authorized_principal`] +/// or [`ProbeWire::is_coherent_disabled`] before its state is trusted. +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProbeWire { + status: String, + auth_mode: String, + role: Option, + source: Option, + can_act: bool, + can_staff: bool, +} + +/// The relay's resolved principal role. Closed vocabulary — an unknown string +/// fails to parse and denies authorization. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProbeRole { + Operator, + Moderator, +} + +impl ProbeRole { + fn parse(s: &str) -> Option { + match s { + "operator" => Some(Self::Operator), + "moderator" => Some(Self::Moderator), + _ => None, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Operator => "operator", + Self::Moderator => "moderator", + } + } +} + +/// How the relay established the principal's role. Closed vocabulary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProbeSource { + Config, + OwnerFallback, + Db, +} + +impl ProbeSource { + fn parse(s: &str) -> Option { + match s { + "config" => Some(Self::Config), + "owner_fallback" => Some(Self::OwnerFallback), + "db" => Some(Self::Db), + _ => None, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Config => "config", + Self::OwnerFallback => "owner_fallback", + Self::Db => "db", + } + } +} + +impl ProbeWire { + /// Validate the complete NIP-98 authorization invariant and return the + /// typed principal only when every field is coherent with the relay + /// contract: `status == "ok"`, `authMode == "nip98"`, a recognised + /// non-null `role`/`source`, `canAct == true`, and + /// `canStaff == (role == operator)`. Any deviation yields `None`, so the + /// caller classifies the response `NotAdminApi` rather than trusting a + /// fail-open 2xx. + fn authorized_principal(&self) -> Option<(ProbeRole, ProbeSource)> { + if self.status != "ok" || self.auth_mode != "nip98" { + return None; + } + let role = ProbeRole::parse(self.role.as_deref()?)?; + let source = ProbeSource::parse(self.source.as_deref()?)?; + if !self.can_act || self.can_staff != (role == ProbeRole::Operator) { + return None; + } + Some((role, source)) + } + + /// Validate the disabled-mode invariant for an unauthenticated 200: + /// `status == "ok"`, `authMode == "disabled"`, no principal, no + /// capabilities. Any deviation is a contract violation (token/nip98 must + /// 401 an unauthenticated caller). + fn is_coherent_disabled(&self) -> bool { + self.status == "ok" + && self.auth_mode == "disabled" + && self.role.is_none() + && self.source.is_none() + && !self.can_act + && !self.can_staff + } +} + +/// Parse a `/probe` response body into a [`ProbeWire`], returning `None` when +/// the Content-Type is not JSON or the body does not match the probe contract. +/// +/// Strict typing rejects unrelated JSON endpoints: a response missing any +/// required field (`status`, `authMode`, `canAct`, `canStaff`) or carrying a +/// wrong-typed field fails to deserialise and yields `None`, so a non-admin +/// origin that happens to return JSON is classified `NotAdminApi` rather than +/// mistaken for the admin API. +fn parse_probe(content_type: &str, bytes: &[u8]) -> Option { + if !content_type + .to_ascii_lowercase() + .starts_with("application/json") + { + return None; + } + serde_json::from_slice::(bytes).ok() +} + +/// Extract the normalised Content-Type base value (strips parameters). +fn response_content_type(resp: &reqwest::Response) -> String { + resp.headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .split(';') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase() +} + +// ── Five typed data commands ────────────────────────────────────────────── + +/// Fetch the reports list. +#[tauri::command] +pub async fn admin_list_reports( + origin: String, + query: AdminReportsQuery, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let q = routes::AdminQuery { + community_id: query.community_id, + status: query.status, + report_type: query.report_type, + target_kind: query.target_kind, + after: query.after, + before: query.before, + limit: query.limit, + }; + let url = origin.route_url(&routes::AdminRoute::ReportsList, &q); + let bytes = fetch_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) +} + +/// Fetch a single report's detail. +#[tauri::command] +pub async fn admin_get_report( + origin: String, + id: String, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let id = + uuid::Uuid::parse_str(&id).map_err(|_| "report id must be a valid UUID".to_string())?; + let url = origin.route_url( + &routes::AdminRoute::ReportDetail { id }, + &routes::AdminQuery::default(), + ); + let bytes = fetch_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) +} + +/// Fetch the feedback list. +#[tauri::command] +pub async fn admin_list_feedback( + origin: String, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let url = origin.route_url( + &routes::AdminRoute::FeedbackList, + &routes::AdminQuery::default(), + ); + let bytes = fetch_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) +} + +/// Fetch a single feedback entry's detail (including imeta attachment metadata). +#[tauri::command] +pub async fn admin_get_feedback( + origin: String, + id: String, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let id = + uuid::Uuid::parse_str(&id).map_err(|_| "feedback id must be a valid UUID".to_string())?; + let url = origin.route_url( + &routes::AdminRoute::FeedbackDetail { id }, + &routes::AdminQuery::default(), + ); + let bytes = fetch_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) +} + +/// Resolve a report — POST /api/admin/v1/reports/{id}/resolve. +/// +/// Body: `{action, request_id, expiration_secs?, reason?}`. +/// The `request_id` is a client-generated UUID for idempotency; the caller +/// must generate once per resolution attempt and reuse on retry. +#[tauri::command] +pub async fn admin_resolve_report( + origin: String, + id: String, + body: serde_json::Value, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let id = + uuid::Uuid::parse_str(&id).map_err(|_| "report id must be a valid UUID".to_string())?; + let url = origin.route_url( + &routes::AdminRoute::ReportResolve { id }, + &routes::AdminQuery::default(), + ); + let body_bytes = + serde_json::to_vec(&body).map_err(|e| format!("failed to serialise request body: {e}"))?; + let bytes = post_admin_json(&url, &body_bytes, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}").into()) +} + +/// Reopen a resolved report — POST /api/admin/v1/reports/{id}/reopen. +/// +/// Body: `{request_id, reason?}`. The `request_id` is a client-generated UUID +/// for idempotency; the caller must generate once per reopen attempt and reuse +/// on retry. Reopen is re-triage only — it moves a `resolved`/`dismissed`/ +/// `escalated` report back to `open` and does not reverse any enforcement +/// action (bans, deletions) taken while it was resolved. +#[tauri::command] +pub async fn admin_reopen_report( + origin: String, + id: String, + body: serde_json::Value, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let id = + uuid::Uuid::parse_str(&id).map_err(|_| "report id must be a valid UUID".to_string())?; + let url = origin.route_url( + &routes::AdminRoute::ReportReopen { id }, + &routes::AdminQuery::default(), + ); + let body_bytes = + serde_json::to_vec(&body).map_err(|e| format!("failed to serialise request body: {e}"))?; + let bytes = post_admin_json(&url, &body_bytes, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}").into()) +} + +/// Cancel a failed enforcement action — POST /api/admin/v1/reports/{id}/cancel. +/// +/// Body: `{actionId}`. Cancel is the only recovery path for a pre-mutation +/// `failed` action: it returns the report to `open` for a fresh resolution. +/// The `actionId` fences the cancel to the failed action the operator observed; +/// a mismatch (already cancelled, superseded, or past the mutation point) is a +/// 409, which the caller treats as "refresh detail". +#[tauri::command] +pub async fn admin_cancel_report( + origin: String, + id: String, + body: serde_json::Value, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let id = + uuid::Uuid::parse_str(&id).map_err(|_| "report id must be a valid UUID".to_string())?; + let url = origin.route_url( + &routes::AdminRoute::ReportCancel { id }, + &routes::AdminQuery::default(), + ); + let body_bytes = + serde_json::to_vec(&body).map_err(|e| format!("failed to serialise request body: {e}"))?; + let bytes = post_admin_json(&url, &body_bytes, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}").into()) +} + +/// Update feedback status — PATCH /api/admin/v1/feedback/{id}. +/// +/// Body: `{status}` where status ∈ {"new","reviewed","archived"}. +#[tauri::command] +pub async fn admin_patch_feedback( + origin: String, + id: String, + body: serde_json::Value, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let id = + uuid::Uuid::parse_str(&id).map_err(|_| "feedback id must be a valid UUID".to_string())?; + let url = origin.route_url( + &routes::AdminRoute::FeedbackPatch { id }, + &routes::AdminQuery::default(), + ); + let body_bytes = + serde_json::to_vec(&body).map_err(|e| format!("failed to serialise request body: {e}"))?; + let bytes = patch_admin_json(&url, &body_bytes, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}").into()) +} + +/// List operators — GET /api/admin/v1/operators. +/// +/// Operator-only. Returns all effective principals with `effectiveRole` and +/// `sources[]` (`config`, `owner_fallback`, `db`). +#[tauri::command] +pub async fn admin_list_operators( + origin: String, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let url = origin.route_url( + &routes::AdminRoute::OperatorsList, + &routes::AdminQuery::default(), + ); + let bytes = fetch_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) +} + +/// Add or update an operator — PUT /api/admin/v1/operators/{pubkey}. +/// +/// Operator-only. Body: `{role}` where role ∈ {"operator","moderator"}. +/// Returns 409 if the pubkey is config-backed (immutable via API). +#[tauri::command] +pub async fn admin_put_operator( + origin: String, + pubkey: String, + body: serde_json::Value, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let pubkey = + routes::HexPubkey::parse(&pubkey).map_err(|e| format!("invalid operator pubkey: {e}"))?; + let url = origin.route_url( + &routes::AdminRoute::OperatorPut { pubkey }, + &routes::AdminQuery::default(), + ); + let body_bytes = + serde_json::to_vec(&body).map_err(|e| format!("failed to serialise request body: {e}"))?; + let bytes = put_admin_json(&url, &body_bytes, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}").into()) +} + +/// Remove an operator — DELETE /api/admin/v1/operators/{pubkey}. +/// +/// Operator-only. Returns 409 if the pubkey is config-backed. +#[tauri::command] +pub async fn admin_delete_operator( + origin: String, + pubkey: String, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + let origin = origin::AdminOrigin::parse(&origin)?; + let pubkey = + routes::HexPubkey::parse(&pubkey).map_err(|e| format!("invalid operator pubkey: {e}"))?; + let url = origin.route_url( + &routes::AdminRoute::OperatorDelete { pubkey }, + &routes::AdminQuery::default(), + ); + let bytes = delete_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; + serde_json::from_slice(&bytes).map_err(|e| format!("invalid JSON from relay: {e}")) +} + +/// Fetch a feedback attachment by SHA-256 hash. +/// +/// The front-end MUST supply `expectedMime` and `expectedSize` from the +/// server-validated `imeta` fields returned by `admin_get_feedback`. The +/// command verifies the relay's `Content-Type` against `expectedMime` and +/// the actual byte count against `expectedSize`. Mismatch or over-cap yields +/// a stable typed error-code string. +/// +/// Returns `tauri::ipc::Response` so bytes cross IPC as a raw `ArrayBuffer`. +#[tauri::command] +pub async fn admin_fetch_feedback_attachment( + origin: String, + feedback_id: String, + sha256: String, + expected_mime: String, + expected_size: u64, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result { + use crate::relay::build_nip98_auth_header_for_keys; + + // Validate inputs before any network activity. + let feedback_id = uuid::Uuid::parse_str(&feedback_id) + .map_err(|_| "admin_attachment_invalid_feedback_id".to_string())?; + let sha256 = routes::AttachmentHash::parse(&sha256) + .map_err(|_| "admin_attachment_invalid_hash".to_string())?; + if expected_size == 0 { + return Err("admin_attachment_invalid_size".to_string()); + } + if expected_size > ATTACHMENT_CAP { + return Err("admin_attachment_too_large".to_string()); + } + if expected_mime.is_empty() { + return Err("admin_attachment_invalid_mime".to_string()); + } + + let origin = origin::AdminOrigin::parse(&origin)?; + let url = origin.route_url( + &routes::AdminRoute::FeedbackAttachment { + id: feedback_id, + sha256, + }, + &routes::AdminQuery::default(), + ); + + let keys = state.signing_keys()?; + let http_client = client::ADMIN_CLIENT + .get() + .ok_or_else(|| "admin client not initialised".to_string())?; + + let auth_header = build_nip98_auth_header_for_keys(&keys, &reqwest::Method::GET, &url, &[]) + .map_err(|e| format!("nip98 build failed: {e}"))?; + + let resp = http_client + .get(&url) + .header(reqwest::header::AUTHORIZATION, &auth_header) + .send() + .await + .map_err(|e| { + tracing::debug!(error = %e, "admin attachment fetch failed"); + "admin_attachment_network_error".to_string() + })?; + + // One retry on 401 with a fresh NIP-98 event. + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + let auth_header2 = + build_nip98_auth_header_for_keys(&keys, &reqwest::Method::GET, &url, &[]) + .map_err(|e| format!("nip98 build failed on retry: {e}"))?; + let resp2 = http_client + .get(&url) + .header(reqwest::header::AUTHORIZATION, auth_header2) + .send() + .await + .map_err(|_| "admin_attachment_network_error".to_string())?; + return finish_attachment_response(resp2, &expected_mime, expected_size).await; + } + + finish_attachment_response(resp, &expected_mime, expected_size).await +} + +// ── Origin storage commands ─────────────────────────────────────────────── + +/// Core storage logic for `get_admin_origin`, parameterised by data directory +/// and resolved pubkey hex. No `tauri::State` — testable with `tempdir`. +/// +/// Reads the per-pubkey JSON file, reparses the stored origin through +/// `AdminOrigin::parse()`, and returns the canonical string. Returns `None` +/// when no file exists. On malformed/invalid content, removes the file and +/// returns `Err` so the caller can surface a visible setup error. +pub(crate) fn get_admin_origin_core( + data_dir: &std::path::Path, + pubkey_hex: &str, +) -> Result, String> { + let path = data_dir.join(format!("admin-console-origin-{pubkey_hex}.json")); + if !path.exists() { + return Ok(None); + } + let content = std::fs::read_to_string(&path) + .map_err(|e| format!("failed to read admin console origin: {e}"))?; + let stored: StoredAdminOrigin = match serde_json::from_str(&content) { + Ok(v) => v, + Err(e) => { + let remove_result = std::fs::remove_file(&path); + return Err(match remove_result { + Ok(()) => format!("stored admin console origin is invalid (removed): {e}"), + Err(re) => format!( + "stored admin console origin is invalid (quarantine failed — {re}): {e}" + ), + }); + } + }; + match origin::AdminOrigin::parse(&stored.origin) { + Ok(o) => Ok(Some(o.as_str().to_string())), + Err(e) => { + let remove_result = std::fs::remove_file(&path); + Err(match remove_result { + Ok(()) => format!("stored admin console origin is invalid (removed): {e}"), + Err(re) => format!( + "stored admin console origin is invalid (quarantine failed — {re}): {e}" + ), + }) + } + } +} + +/// Core storage logic for `set_admin_origin`, parameterised by data directory +/// and resolved pubkey hex. No `tauri::State` — testable with `tempdir`. +/// +/// Validates and persists `raw_origin`. Pass `None` to clear. Returns the +/// canonical origin string on success, or `None` on clear. +pub(crate) fn set_admin_origin_core( + data_dir: &std::path::Path, + pubkey_hex: &str, + raw_origin: Option, +) -> Result, String> { + use crate::managed_agents::storage::atomic_write_json_restricted; + let path = data_dir.join(format!("admin-console-origin-{pubkey_hex}.json")); + match raw_origin { + None => { + if path.exists() { + std::fs::remove_file(&path) + .map_err(|e| format!("failed to remove admin console origin: {e}"))?; + } + Ok(None) + } + Some(raw) => { + let canonical = origin::AdminOrigin::parse(&raw)?.as_str().to_string(); + let payload = serde_json::to_vec_pretty(&StoredAdminOrigin { + origin: canonical.clone(), + }) + .map_err(|e| format!("failed to serialise admin console origin: {e}"))?; + atomic_write_json_restricted(&path, &payload)?; + Ok(Some(canonical)) + } + } +} + +/// Return the persisted admin console origin for the active pubkey, or `None` +/// if none has been saved yet. +/// +/// `expected_pubkey` is checked against the active signing key before +/// reading. This is a defence-in-depth guard: if a delayed IPC call arrives +/// after the user has switched identities, the mismatch is caught here and the +/// read is rejected so stale-session data cannot surface in the new session. +/// +/// The stored value is reparsed through `AdminOrigin::parse()` on every read. +/// If the stored content is invalid, it is removed and an error returned so +/// the settings card shows a visible setup error rather than silently degrading. +#[tauri::command] +pub fn get_admin_origin( + expected_pubkey: Option, + app: tauri::AppHandle, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result, String> { + // Fail closed: never derive the pubkey from an error fallback. + let pubkey = validate_pubkey_hex(state.signing_keys()?.public_key().to_hex())?; + // If the caller supplied an expected pubkey, reject when it no longer + // matches the active key — a delayed IPC from a prior session. + if let Some(ref expected) = expected_pubkey { + if *expected != pubkey { + return Err( + "admin origin read rejected: active identity changed since request was sent" + .to_string(), + ); + } + } + use tauri::Manager as _; + let dir = app + .path() + .app_data_dir() + .map_err(|e| format!("failed to resolve app data dir: {e}"))?; + std::fs::create_dir_all(&dir).map_err(|e| format!("failed to create app data dir: {e}"))?; + get_admin_origin_core(&dir, &pubkey) +} + +/// Validate and persist the admin console origin for the active pubkey. +/// +/// `expected_pubkey` guards against delayed IPC: if the active signing key no +/// longer matches `expected_pubkey`, the write is rejected to prevent a save +/// started under identity A from writing into identity B's storage namespace. +/// +/// Passes `raw_origin` through `AdminOrigin::parse` to normalise and validate +/// it before writing. Pass `None` to clear the stored origin. +#[tauri::command] +pub fn set_admin_origin( + raw_origin: Option, + expected_pubkey: Option, + app: tauri::AppHandle, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result, String> { + // Fail closed: never derive the pubkey from an error fallback. + let pubkey = validate_pubkey_hex(state.signing_keys()?.public_key().to_hex())?; + if let Some(ref expected) = expected_pubkey { + if *expected != pubkey { + return Err( + "admin origin write rejected: active identity changed since request was sent" + .to_string(), + ); + } + } + use tauri::Manager as _; + let dir = app + .path() + .app_data_dir() + .map_err(|e| format!("failed to resolve app data dir: {e}"))?; + std::fs::create_dir_all(&dir).map_err(|e| format!("failed to create app data dir: {e}"))?; + set_admin_origin_core(&dir, &pubkey, raw_origin) +} + +/// On-disk shape for the persisted admin console origin. +#[derive(serde::Serialize, serde::Deserialize)] +struct StoredAdminOrigin { + origin: String, +} + +/// Validate that `hex` is exactly 64 lowercase hexadecimal characters. +/// +/// `nostr::Keys::public_key().to_hex()` always produces this form, but this +/// check serves as a defence-in-depth guard against future API changes or +/// unexpected fallbacks that could produce a non-canonical string and silently +/// corrupt the filename-based per-pubkey namespace. +fn validate_pubkey_hex(hex: String) -> Result { + if hex.len() == 64 && hex.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) { + Ok(hex) + } else { + Err("signing key produced an unexpected pubkey format; cannot scope storage".to_string()) + } +} + +// ── NIP-11 admin-origin discovery ───────────────────────────────────────── + +/// Minimal projection of the relay's NIP-11 information document — only the +/// field needed to auto-discover the admin console origin. Unknown fields are +/// ignored, so a full NIP-11 document deserializes cleanly. +#[derive(serde::Deserialize)] +struct AdminApiInfo { + #[serde(default)] + admin_api: Option, +} + +/// Validate a relay-advertised `admin_api` value into a canonical origin. +/// +/// The value is untrusted relay input: it is accepted only if it passes the +/// same `AdminOrigin` validation applied to operator-entered URLs (loopback +/// `http` or any `https`, origin only — no path, query, fragment, or +/// credentials). An absent or invalid value yields `None` so the desktop falls +/// back to manual entry rather than probing a malformed origin. +fn admin_origin_from_nip11(info: &AdminApiInfo) -> Option { + let raw = info.admin_api.as_deref()?; + origin::AdminOrigin::parse(raw) + .ok() + .map(|origin| origin.as_str().to_string()) +} + +/// Fetch the relay's NIP-11 document and extract a validated admin origin. +/// +/// Returns `Ok(Some(origin))` when the relay advertises a valid `admin_api`, +/// `Ok(None)` when the field is absent or fails validation, and `Err` on a +/// transport or non-2xx failure. Split from the Tauri command so it can be +/// exercised against a live test server without constructing `AppState`. +async fn discover_admin_origin_at( + client: &reqwest::Client, + relay_http_base: &str, +) -> Result, String> { + use crate::relay::{classify_request_error, parse_json_response, relay_error_message}; + + let url = format!("{}/info", relay_http_base.trim_end_matches('/')); + let response = client + .get(url) + .header("Accept", "application/nostr+json") + .send() + .await + .map_err(|error| classify_request_error(&error))?; + + if !response.status().is_success() { + return Err(relay_error_message(response).await); + } + + let info = parse_json_response::(response).await?; + Ok(admin_origin_from_nip11(&info)) +} + +/// Auto-discover the admin console origin from the connected relay's NIP-11 +/// document. Returns the canonical origin when the relay advertises a valid +/// `admin_api`, or `None` when it does not — the caller falls back to manual +/// entry. Mirrors the native NIP-11 fetch used by `relay_requires_membership`. +#[tauri::command] +pub async fn admin_discover_origin( + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result, String> { + let base = crate::relay::relay_api_base_url_with_override(&state); + discover_admin_origin_at(&state.http_client, &base).await +} + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/admin/mod_tests.rs b/desktop/src-tauri/src/commands/admin/mod_tests.rs new file mode 100644 index 00000000000..92950c2316e --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/mod_tests.rs @@ -0,0 +1,996 @@ +//! Unit and integration tests for `commands/admin/mod.rs` (split to keep `mod.rs` +//! under the 1000-line file-size ratchet). +//! +//! Included via `#[path = "mod_tests.rs"] mod tests;` at the bottom of `mod.rs`, +//! so `use super::*` gives access to all items in that module. + +use super::*; +use crate::commands::admin::{origin::AdminOrigin, routes::AdminRoute}; +use std::sync::Arc; + +/// Type alias for the request inspector closure passed to `serve_sequence_inspect`. +type RequestInspector = std::sync::Arc; + +/// Parsed HTTP request data for transport-layer assertions. +#[derive(Debug)] +struct RequestRecord { + method: String, + path: String, + auth: Option, +} + +// ── AdminOrigin × routes integration ───────────────────────────────────── + +#[test] +fn reports_list_url_contains_api_prefix() { + let o = AdminOrigin::parse("https://admin.example.com").unwrap(); + let url = o.route_url(&AdminRoute::ReportsList, &routes::AdminQuery::default()); + assert!( + url.starts_with("https://admin.example.com/api/admin/v1/"), + "URL must include /api/admin/v1/ prefix: {url}" + ); +} + +#[test] +fn localhost_uses_http_prefix() { + let o = AdminOrigin::parse("http://localhost:3000").unwrap(); + let url = o.route_url(&AdminRoute::FeedbackList, &routes::AdminQuery::default()); + assert!(url.starts_with("http://localhost:3000/api/admin/v1/")); +} + +// ── Attachment command validation (calls production validators) ─────────── + +#[test] +fn attachment_hash_valid_lowercase_hex_accepted() { + let result = routes::AttachmentHash::parse(&"a".repeat(64)); + assert!(result.is_ok(), "64 lowercase hex chars must be accepted"); +} + +#[test] +fn attachment_hash_uppercase_rejected_by_production_validator() { + let result = routes::AttachmentHash::parse(&"A".repeat(64)); + assert!( + result.is_err(), + "uppercase hex must be rejected — relay returns 404 for uppercase hashes" + ); +} + +#[test] +fn attachment_hash_63_chars_rejected_by_production_validator() { + let result = routes::AttachmentHash::parse(&"a".repeat(63)); + assert!(result.is_err(), "63 chars must be rejected"); +} + +#[test] +fn feedback_id_malformed_uuid_rejected_by_production_validator() { + let result = uuid::Uuid::parse_str("not-a-uuid"); + assert!(result.is_err(), "non-UUID feedback id must be rejected"); +} + +#[test] +fn feedback_id_slash_injection_rejected() { + let result = uuid::Uuid::parse_str("../../../etc/passwd"); + assert!( + result.is_err(), + "path traversal in feedback id must be rejected" + ); +} + +#[test] +fn feedback_id_query_injection_rejected() { + let result = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001?x=y"); + assert!( + result.is_err(), + "query injection in feedback id must be rejected" + ); +} + +// ── Content-Type matching ───────────────────────────────────────────────── + +#[test] +fn content_type_matching_is_case_insensitive_and_strips_params() { + let raw = "Image/PNG; charset=binary"; + let normalised = raw.split(';').next().unwrap().trim().to_ascii_lowercase(); + assert_eq!(normalised, "image/png"); +} + +// ── parse_probe ─────────────────────────────────────────────────────────── + +/// A well-formed `/probe` response body. `role`/`source` are JSON literals +/// (`"operator"`, `null`, …) so the helper can build both nip98 and +/// token/disabled shapes. +fn probe_json(auth_mode: &str, role: &str, source: &str, can_act: bool, can_staff: bool) -> String { + format!( + r#"{{"status":"ok","authMode":"{auth_mode}","role":{role},"source":{source},"canAct":{can_act},"canStaff":{can_staff}}}"# + ) +} + +#[test] +fn parse_probe_operator_nip98() { + let body = probe_json("nip98", r#""operator""#, r#""config""#, true, true); + let p = parse_probe("application/json", body.as_bytes()).expect("valid operator probe"); + assert_eq!(p.auth_mode, "nip98"); + assert_eq!(p.role.as_deref(), Some("operator")); + assert_eq!(p.source.as_deref(), Some("config")); +} + +#[test] +fn parse_probe_disabled_has_null_role() { + let body = probe_json("disabled", "null", "null", false, false); + let p = parse_probe("application/json", body.as_bytes()).expect("valid disabled probe"); + assert_eq!(p.auth_mode, "disabled"); + assert_eq!(p.role, None); + assert_eq!(p.source, None); +} + +#[test] +fn parse_probe_rejects_non_json_content_type() { + let body = probe_json("nip98", r#""operator""#, r#""config""#, true, true); + assert!(parse_probe("text/html", body.as_bytes()).is_none()); + assert!(parse_probe("", body.as_bytes()).is_none()); +} + +#[test] +fn parse_probe_rejects_missing_required_field() { + // Missing `canStaff` — an unrelated JSON endpoint must not classify as the + // admin API. + let body = + r#"{"status":"ok","authMode":"nip98","role":"operator","source":"config","canAct":true}"#; + assert!(parse_probe("application/json", body.as_bytes()).is_none()); +} + +#[test] +fn parse_probe_rejects_wrong_typed_field() { + // `canAct` as a string, not a bool. + let body = r#"{"status":"ok","authMode":"nip98","role":"operator","source":"config","canAct":"yes","canStaff":true}"#; + assert!(parse_probe("application/json", body.as_bytes()).is_none()); +} + +#[test] +fn parse_probe_rejects_non_object() { + assert!(parse_probe("application/json", b"[]").is_none()); + assert!(parse_probe("application/json", b"\"string\"").is_none()); + assert!(parse_probe("application/json", b"not json").is_none()); +} + +// ── authorized_principal / is_coherent_disabled invariants ──────────────── +// +// Structural deserialisation (parse_probe) is necessary but not sufficient: +// a 2xx body must also satisfy the full relay contract before its state is +// trusted. These pin every branch of that invariant. + +/// Parse a body known to be structurally valid, then validate it. +fn authorized(body: &str) -> Option<(ProbeRole, ProbeSource)> { + parse_probe("application/json", body.as_bytes()) + .expect("structurally valid probe") + .authorized_principal() +} + +#[test] +fn authorized_principal_accepts_every_coherent_shape() { + // Operator (canStaff true) and moderator (canStaff false) across all three + // recognised sources — each must yield the typed principal. + let cases: &[(String, ProbeRole, ProbeSource)] = &[ + ( + probe_json("nip98", r#""operator""#, r#""config""#, true, true), + ProbeRole::Operator, + ProbeSource::Config, + ), + ( + probe_json("nip98", r#""operator""#, r#""owner_fallback""#, true, true), + ProbeRole::Operator, + ProbeSource::OwnerFallback, + ), + ( + probe_json("nip98", r#""moderator""#, r#""db""#, true, false), + ProbeRole::Moderator, + ProbeSource::Db, + ), + ]; + for (body, role, source) in cases { + assert_eq!(authorized(body), Some((*role, *source))); + } +} + +#[test] +fn authorized_principal_rejects_every_incoherent_shape() { + // Structurally valid probe bodies the relay never emits under nip98; + // accepting any is fail-open. One invariant broken per row, top to bottom: + // wrong status, non-nip98 authMode, missing role, unknown role, missing + // source, unknown source, false canAct, operator lacking canStaff, + // moderator carrying canStaff. + let pj = probe_json; + let cases = [ + pj("nip98", r#""operator""#, r#""config""#, true, true) + .replace(r#""status":"ok""#, r#""status":"error""#), + pj("token", "null", "null", false, false), + pj("nip98", "null", r#""config""#, true, true), + pj("nip98", r#""superuser""#, r#""config""#, true, true), + pj("nip98", r#""operator""#, "null", true, true), + pj("nip98", r#""operator""#, r#""ldap""#, true, true), + pj("nip98", r#""operator""#, r#""config""#, false, true), + pj("nip98", r#""operator""#, r#""config""#, true, false), + pj("nip98", r#""moderator""#, r#""config""#, true, true), + ]; + for (i, body) in cases.iter().enumerate() { + assert_eq!(authorized(body), None, "row {i} must be rejected"); + } +} + +#[test] +fn is_coherent_disabled_accepts_only_canonical_disabled() { + // Canonical disabled authorizes; nip98 mode and any disabled body claiming + // a role/source or a capability is incoherent and must be rejected. + let disabled = probe_json("disabled", "null", "null", false, false); + assert!(parse_probe("application/json", disabled.as_bytes()) + .unwrap() + .is_coherent_disabled()); + + let incoherent = [ + probe_json("nip98", r#""operator""#, r#""config""#, true, true), + probe_json("disabled", r#""operator""#, "null", false, false), + probe_json("disabled", "null", "null", true, false), + ]; + for body in &incoherent { + assert!(!parse_probe("application/json", body.as_bytes()) + .unwrap() + .is_coherent_disabled()); + } +} + +// ── Storage core through production code ───────────────────────────────── +// +// All tests call `get_admin_origin_core` / `set_admin_origin_core` directly +// — the `pub(crate)` functions parameterised by data directory and pubkey +// hex. No `tauri::State` needed; each test uses a `tempdir` for isolation. + +#[test] +fn storage_round_trip_returns_canonical_origin() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "a".repeat(64); + let origin = "https://admin.example.com"; + let canonical = set_admin_origin_core(dir.path(), &pubkey, Some(origin.to_string())) + .unwrap() + .unwrap(); + assert!( + canonical.starts_with("https://admin.example.com"), + "canonical origin must start with the input origin: {canonical}" + ); + let read_back = get_admin_origin_core(dir.path(), &pubkey).unwrap().unwrap(); + assert_eq!( + canonical, read_back, + "read-back must match the canonical form returned by set" + ); +} + +#[test] +fn storage_two_identities_are_isolated() { + let dir = tempfile::tempdir().unwrap(); + let pubkey_a = "a".repeat(64); + let pubkey_b = "b".repeat(64); + set_admin_origin_core( + dir.path(), + &pubkey_a, + Some("https://admin-a.example.com".to_string()), + ) + .unwrap(); + set_admin_origin_core( + dir.path(), + &pubkey_b, + Some("https://admin-b.example.com".to_string()), + ) + .unwrap(); + + let a = get_admin_origin_core(dir.path(), &pubkey_a) + .unwrap() + .unwrap(); + let b = get_admin_origin_core(dir.path(), &pubkey_b) + .unwrap() + .unwrap(); + assert!( + a.contains("admin-a"), + "pubkey_a must read its own origin: {a}" + ); + assert!( + b.contains("admin-b"), + "pubkey_b must read its own origin: {b}" + ); + // No cross-read: each key sees only its own value. + assert!( + !a.contains("admin-b"), + "pubkey_a must not read pubkey_b's origin" + ); + assert!( + !b.contains("admin-a"), + "pubkey_b must not read pubkey_a's origin" + ); +} + +#[test] +fn storage_malformed_json_is_quarantined_and_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "c".repeat(64); + // Write a corrupt file directly — bypassing set_admin_origin_core. + let path = dir + .path() + .join(format!("admin-console-origin-{pubkey}.json")); + std::fs::write(&path, b"not valid json").unwrap(); + assert!(path.exists(), "corrupt file must exist before read"); + + let result = get_admin_origin_core(dir.path(), &pubkey); + assert!( + result.is_err(), + "malformed JSON must return Err: {result:?}" + ); + // Quarantine: the file must have been removed. + assert!( + !path.exists(), + "quarantine failed: corrupt file must be removed after error" + ); +} + +#[test] +fn storage_forbidden_path_bearing_origin_is_quarantined_and_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "d".repeat(64); + // Write a file whose stored origin contains a path component — + // AdminOrigin::parse must reject it, triggering quarantine. + let path = dir + .path() + .join(format!("admin-console-origin-{pubkey}.json")); + let payload = serde_json::json!({ "origin": "https://admin.example.com/forbidden/path" }); + std::fs::write(&path, serde_json::to_vec(&payload).unwrap()).unwrap(); + assert!(path.exists(), "seeded file must exist before read"); + + let result = get_admin_origin_core(dir.path(), &pubkey); + assert!( + result.is_err(), + "origin with path must return Err on reparse: {result:?}" + ); + assert!( + !path.exists(), + "quarantine failed: forbidden-origin file must be removed after error" + ); +} + +#[test] +fn storage_clear_removes_file() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "e".repeat(64); + set_admin_origin_core( + dir.path(), + &pubkey, + Some("https://admin.example.com".to_string()), + ) + .unwrap(); + let path = dir + .path() + .join(format!("admin-console-origin-{pubkey}.json")); + assert!(path.exists(), "file must exist after set"); + + let result = set_admin_origin_core(dir.path(), &pubkey, None).unwrap(); + assert_eq!(result, None, "clear must return None"); + assert!(!path.exists(), "clear must remove the file"); +} + +#[test] +fn storage_no_file_returns_none() { + let dir = tempfile::tempdir().unwrap(); + let pubkey = "f".repeat(64); + let result = get_admin_origin_core(dir.path(), &pubkey).unwrap(); + assert_eq!(result, None, "absent file must return None"); +} + +// ── validate_pubkey_hex ─────────────────────────────────────────────────── + +#[test] +fn pubkey_hex_valid_64_lowercase() { + assert!(validate_pubkey_hex("a".repeat(64)).is_ok()); +} + +#[test] +fn pubkey_hex_uppercase_rejected() { + assert!(validate_pubkey_hex("A".repeat(64)).is_err()); +} + +#[test] +fn pubkey_hex_empty_rejected() { + assert!(validate_pubkey_hex("".to_string()).is_err()); +} + +#[test] +fn pubkey_hex_63_chars_rejected() { + assert!(validate_pubkey_hex("a".repeat(63)).is_err()); +} + +// ── Live stub helpers ───────────────────────────────────────────────────── + +/// Build a fake Response using a live TCP listener. +async fn fake_response(status: u16, headers: &str, body: &str) -> reqwest::Response { + use std::io::{Read, Write}; + client::init_admin_client(); + let client = client::ADMIN_CLIENT.get().unwrap(); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let body_bytes = body.as_bytes().to_vec(); + let body_len = body_bytes.len(); + let response = format!( + "HTTP/1.1 {status} OK\r\nContent-Length: {body_len}\r\n{headers}Connection: close\r\n\r\n" + ); + let response_bytes = response.into_bytes(); + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + let _ = stream.write_all(&response_bytes); + let _ = stream.write_all(&body_bytes); + let _ = stream.flush(); + } + }); + client + .get(format!("http://{addr}/api/admin/v1/reports")) + .send() + .await + .unwrap() +} + +/// Serve sequential HTTP responses from a background thread. +/// +/// For each request the listener reads the raw HTTP bytes, calls the +/// provided inspector closure with the raw request bytes and slot index, +/// then sends the pre-configured response. The inspector records request +/// details post-hoc for assertion after the probe completes. +async fn serve_sequence_inspect( + responses: Vec<(&'static str, &'static str, &'static str)>, + inspect: Option, +) -> std::net::SocketAddr { + use std::io::{Read, Write}; + client::init_admin_client(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + for (idx, (status, headers, body)) in responses.into_iter().enumerate() { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 8192]; + let n = stream.read(&mut buf).unwrap_or(0); + // Invoke the inspector with the raw request bytes. + if let Some(ref f) = inspect { + f(idx, &buf[..n]); + } + let body_bytes = body.as_bytes(); + let response = format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\n{headers}Connection: close\r\n\r\n", + body_bytes.len() + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.write_all(body_bytes); + let _ = stream.flush(); + } + } + }); + addr +} + +/// Serve sequential responses without request inspection (backward compat). +async fn serve_sequence( + responses: Vec<(&'static str, &'static str, &'static str)>, +) -> std::net::SocketAddr { + serve_sequence_inspect(responses, None).await +} + +/// Serve a two-slot NIP-98 stub where the second response is gated on the +/// received Authorization header matching `expected_token`. +/// +/// Slot 0: always 401 Unauthorized + `WWW-Authenticate: Nostr` (triggers retry). +/// Slot 1: 200 OK with JSON body if the received Authorization header equals +/// `expected_token`; plain 401 (no Nostr challenge) otherwise — a mismatch +/// means the production header call was missing, so the probe returns +/// Nip98Denied and the caller's `Nip98Authorized` assertion fails. +/// +/// Both slots are recorded in the returned `Arc>>`. +async fn serve_gated_nip98( + expected_token: String, + authorized_body: &'static str, +) -> ( + std::net::SocketAddr, + Arc>>, +) { + use std::io::{Read, Write}; + client::init_admin_client(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let records: Arc>> = + Arc::new(std::sync::Mutex::new(Vec::new())); + let records_bg = Arc::clone(&records); + std::thread::spawn(move || { + for slot in 0..2usize { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 8192]; + let n = stream.read(&mut buf).unwrap_or(0); + let text = std::str::from_utf8(&buf[..n]).unwrap_or(""); + // Parse request line and Authorization header. + let first_line = text.lines().next().unwrap_or(""); + let mut parts = first_line.splitn(3, ' '); + let method = parts.next().unwrap_or("").to_string(); + let path = parts.next().unwrap_or("").to_string(); + let auth = text + .lines() + .find(|l| l.to_ascii_lowercase().starts_with("authorization:")) + .map(|l| l[l.find(':').unwrap() + 1..].trim().to_string()); + records_bg.lock().unwrap().push(RequestRecord { + method, + path, + auth: auth.clone(), + }); + // Gate: slot 0 always challenges; slot 1 returns 200 only on + // header match, 401 (no challenge) otherwise. + let (status, headers, body): (&str, &str, &str) = if slot == 0 { + ("401 Unauthorized", "WWW-Authenticate: Nostr\r\n", "") + } else if auth.as_deref() == Some(expected_token.as_str()) { + ( + "200 OK", + "Content-Type: application/json\r\n", + authorized_body, + ) + } else { + // Mismatch or absent header → plain 401 (no Nostr challenge). + // admin_probe_inner sees a non-Nostr 401 after the retry and + // returns Nip98Denied, causing the caller's Nip98Authorized + // assertion to fail — which is the intended mutation catch. + ("401 Unauthorized", "", "") + }; + let resp = format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\n{headers}Connection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.write_all(body.as_bytes()); + let _ = stream.flush(); + } + } + }); + (addr, records) +} + +// ── is_probe_response_intercepted ──────────────────────────────────────── + +#[tokio::test] +async fn probe_html_200_classified_as_intercepted() { + let resp = fake_response( + 200, + "Content-Type: text/html; charset=utf-8\r\n", + "Sign in", + ) + .await; + assert!(is_probe_response_intercepted(&resp)); +} + +#[tokio::test] +async fn probe_json_200_not_classified_as_intercepted() { + let resp = fake_response( + 200, + "Content-Type: application/json\r\n", + r#"{"status":"ok","authMode":"disabled","role":null,"source":null,"canAct":false,"canStaff":false}"#, + ) + .await; + assert!(!is_probe_response_intercepted(&resp)); +} + +#[tokio::test] +async fn probe_json_200_with_valid_probe_parses() { + let body = probe_json("nip98", r#""operator""#, r#""config""#, true, true); + let resp = fake_response(200, "Content-Type: application/json\r\n", &body).await; + assert!(!is_probe_response_intercepted(&resp)); + let ct = response_content_type(&resp); + let bytes = read_bounded(resp, PROBE_JSON_CAP).await.unwrap(); + assert!(parse_probe(&ct, &bytes).is_some()); +} + +#[tokio::test] +async fn probe_json_200_bare_garbage_not_admin_api() { + let resp = fake_response(200, "Content-Type: application/json\r\n", "[1,2,3]").await; + let ct = response_content_type(&resp); + let bytes = read_bounded(resp, PROBE_JSON_CAP).await.unwrap(); + assert!(parse_probe(&ct, &bytes).is_none()); +} + +// ── admin_probe_inner end-to-end state machine ──────────────────────────── + +#[tokio::test] +async fn probe_inner_html_200_is_network_or_intercepted() { + let addr = serve_sequence(vec![( + "200 OK", + "Content-Type: text/html\r\n", + "sign in", + )]) + .await; + let result = admin_probe_inner( + &format!("http://{addr}"), + None:: Result>, + ) + .await + .unwrap(); + assert!(matches!(result, AdminProbeResult::NetworkOrIntercepted)); +} + +#[tokio::test] +async fn probe_inner_malformed_json_200_is_not_admin_api() { + let addr = serve_sequence(vec![( + "200 OK", + "Content-Type: application/json\r\n", + "not valid json", + )]) + .await; + let result = admin_probe_inner( + &format!("http://{addr}"), + None:: Result>, + ) + .await + .unwrap(); + assert!(matches!(result, AdminProbeResult::NotAdminApi)); +} + +#[tokio::test] +async fn probe_inner_disabled_probe_200_is_disabled() { + let body = probe_json("disabled", "null", "null", false, false); + let body_static: &'static str = Box::leak(body.into_boxed_str()); + let addr = serve_sequence(vec![( + "200 OK", + "Content-Type: application/json\r\n", + body_static, + )]) + .await; + let result = admin_probe_inner( + &format!("http://{addr}"), + None:: Result>, + ) + .await + .unwrap(); + assert!(matches!(result, AdminProbeResult::Disabled)); +} + +#[tokio::test] +async fn probe_inner_nip98_authmode_200_without_auth_is_not_admin_api() { + // A relay must 401 an unauthenticated caller in nip98/token mode. A 200 + // carrying `authMode: "nip98"` (no 401 challenge) is a contract violation + // and must not be classified as Disabled. + let body = probe_json("nip98", r#""operator""#, r#""config""#, true, true); + let body_static: &'static str = Box::leak(body.into_boxed_str()); + let addr = serve_sequence(vec![( + "200 OK", + "Content-Type: application/json\r\n", + body_static, + )]) + .await; + let result = admin_probe_inner( + &format!("http://{addr}"), + None:: Result>, + ) + .await + .unwrap(); + assert!(matches!(result, AdminProbeResult::NotAdminApi)); +} + +#[tokio::test] +async fn probe_inner_bare_garbage_200_is_not_admin_api() { + // A JSON body that isn't a probe envelope must not classify as admin API. + let addr = serve_sequence(vec![( + "200 OK", + "Content-Type: application/json\r\n", + "[1,2,3]", + )]) + .await; + let result = admin_probe_inner( + &format!("http://{addr}"), + None:: Result>, + ) + .await + .unwrap(); + assert!(matches!(result, AdminProbeResult::NotAdminApi)); +} + +#[tokio::test] +async fn probe_inner_persistent_401_is_nip98_denied() { + let addr = serve_sequence(vec![ + ("401 Unauthorized", "WWW-Authenticate: Nostr\r\n", ""), + ("401 Unauthorized", "", ""), + ]) + .await; + let sign = |_url: &str| -> Result { Ok("Nostr dGVzdA==".to_string()) }; + let result = admin_probe_inner(&format!("http://{addr}"), Some(sign)) + .await + .unwrap(); + assert!(matches!(result, AdminProbeResult::Nip98Denied)); +} + +#[tokio::test] +async fn probe_inner_nip98_challenge_then_json_200_is_authorized_and_asserts_auth_header() { + // Verifies: + // 1. probe state machine produces Nip98Authorized on a Nostr 401→200 sequence. + // 2. The second request carries an Authorization header equal to the signing + // closure's token — tested by the gated stub: slot 1 returns 200 only + // when the received Authorization header matches the expected token; any + // mismatch or absent header returns a plain 401, making the state machine + // return Nip98Denied and failing the Nip98Authorized assertion. + // 3. The first request carries no Authorization header. + // 4. Deleting the `.header(AUTHORIZATION, …)` production line causes the + // stub to receive no header on slot 1, return 401, and the test fails. + + let expected_token = "Nostr dGVzdA==".to_string(); + let expected_token_for_sign = expected_token.clone(); + + let valid_body = probe_json("nip98", r#""operator""#, r#""config""#, true, true); + let valid_body_static: &'static str = Box::leak(valid_body.into_boxed_str()); + + // serve_gated_nip98: slot 0 always challenges; slot 1 checks the Authorization + // header and returns 200 on match, 401 on mismatch/absent. + let (addr, records) = serve_gated_nip98(expected_token, valid_body_static).await; + + let sign = move |_url: &str| -> Result { Ok(expected_token_for_sign.clone()) }; + let result = admin_probe_inner(&format!("http://{addr}"), Some(sign)) + .await + .unwrap(); + + // The relay-resolved role/source must be carried through to the UI so the + // Staffing tab renders for an operator. + assert!( + matches!( + &result, + AdminProbeResult::Nip98Authorized { role, source } + if role.as_deref() == Some("operator") && source.as_deref() == Some("config") + ), + "expected Nip98Authorized operator/config, got {result:?}" + ); + + let records = records.lock().unwrap(); + assert_eq!(records.len(), 2, "exactly two requests must have been made"); + + // Request 0: unauthenticated GET — no Authorization header. + assert_eq!( + records[0].method, "GET", + "slot-0 must be GET; got {:?}", + records[0].method + ); + assert!( + records[0].path.contains("/api/admin/v1/probe"), + "slot-0 must target the probe endpoint; got {:?}", + records[0].path + ); + assert!( + records[0].auth.is_none(), + "slot-0 must carry no Authorization; got {:?}", + records[0].auth + ); + + // Request 1: authenticated retry — Authorization must equal the signing token. + // The stub already enforced this (returned 200 only on match), so this + // post-hoc assertion documents the observed value for auditability. + assert_eq!( + records[1].method, "GET", + "slot-1 must be GET; got {:?}", + records[1].method + ); + assert!( + records[1].path.contains("/api/admin/v1/probe"), + "slot-1 must target the probe endpoint; got {:?}", + records[1].path + ); + assert_eq!( + records[1].auth.as_deref(), + Some("Nostr dGVzdA=="), + "slot-1 Authorization must equal the signing closure token" + ); +} + +#[tokio::test] +async fn probe_inner_missing_auth_header_fails_to_authorize() { + // Verifies the no-sign path: when no signing closure is provided and the + // server issues a Nostr challenge, admin_probe_inner returns Nip98Denied. + // The production code only calls `sign(url)?` when a signing closure is + // Some; passing None causes the signing step to be skipped entirely, so + // no Authorization header is attached and the probe returns Nip98Denied + // without making a second request. + let addr = serve_sequence(vec![( + "401 Unauthorized", + "WWW-Authenticate: Nostr\r\n", + "", + )]) + .await; + let result = admin_probe_inner( + &format!("http://{addr}"), + None:: Result>, + ) + .await + .unwrap(); + assert!(matches!(result, AdminProbeResult::Nip98Denied)); +} + +#[tokio::test] +async fn probe_inner_authenticated_302_is_network_or_intercepted() { + let addr = serve_sequence(vec![ + ("401 Unauthorized", "WWW-Authenticate: Nostr\r\n", ""), + ( + "302 Found", + "Location: https://cloudflareaccess.com/\r\n", + "", + ), + ]) + .await; + let sign = |_url: &str| -> Result { Ok("Nostr dGVzdA==".to_string()) }; + let result = admin_probe_inner(&format!("http://{addr}"), Some(sign)) + .await + .unwrap(); + assert!( + matches!(result, AdminProbeResult::NetworkOrIntercepted), + "authenticated 302 must be NetworkOrIntercepted, got {result:?}" + ); +} + +#[tokio::test] +async fn probe_inner_bearer_401_is_token_mode() { + let addr = serve_sequence(vec![( + "401 Unauthorized", + "WWW-Authenticate: Bearer realm=\"admin\"\r\n", + "", + )]) + .await; + let result = admin_probe_inner( + &format!("http://{addr}"), + None:: Result>, + ) + .await + .unwrap(); + assert!(matches!(result, AdminProbeResult::TokenMode)); +} + +#[tokio::test] +async fn probe_inner_no_sign_on_nostr_challenge_is_nip98_denied() { + let addr = serve_sequence(vec![( + "401 Unauthorized", + "WWW-Authenticate: Nostr\r\n", + "", + )]) + .await; + let result = admin_probe_inner( + &format!("http://{addr}"), + None:: Result>, + ) + .await + .unwrap(); + assert!(matches!(result, AdminProbeResult::Nip98Denied)); +} + +// ── NIP-11 admin-origin discovery ───────────────────────────────────────── + +// Pure-function tests for admin_origin_from_nip11 — a thin wrapper over +// AdminOrigin::parse (whose rules are exhaustively covered in origin.rs). +// These pin only the wrapper's own three outcomes. + +#[test] +fn discover_parse_accepts_valid_value() { + let info = AdminApiInfo { + admin_api: Some("http://127.0.0.1:3000".to_string()), + }; + assert_eq!( + admin_origin_from_nip11(&info).as_deref(), + Some("http://127.0.0.1:3000") + ); +} + +#[test] +fn discover_parse_none_when_absent() { + let info = AdminApiInfo { admin_api: None }; + assert_eq!(admin_origin_from_nip11(&info), None); +} + +#[test] +fn discover_parse_rejects_invalid_value() { + // A non-loopback http origin is rejected by AdminOrigin::parse — the + // untrusted advertised value must not become a probe target. + let info = AdminApiInfo { + admin_api: Some("http://admin.example.com".to_string()), + }; + assert_eq!(admin_origin_from_nip11(&info), None); +} + +#[tokio::test] +async fn discover_returns_origin_when_admin_api_advertised() { + // The relay's NIP-11 doc advertises a valid loopback admin_api → the + // canonical origin is returned so the desktop can auto-probe it. + let body = + r#"{"name":"Buzz Relay","supported_nips":[1,11],"admin_api":"http://127.0.0.1:3000"}"#; + let addr = serve_sequence(vec![( + "200 OK", + "Content-Type: application/nostr+json\r\n", + body, + )]) + .await; + let client = reqwest::Client::new(); + let result = discover_admin_origin_at(&client, &format!("http://{addr}")) + .await + .unwrap(); + assert_eq!(result.as_deref(), Some("http://127.0.0.1:3000")); +} + +#[tokio::test] +async fn discover_returns_none_when_admin_api_absent() { + // A relay that does not configure an admin surface omits admin_api → the + // desktop falls back to manual entry (Ok(None), never an error). + let body = r#"{"name":"Buzz Relay","supported_nips":[1,11]}"#; + let addr = serve_sequence(vec![( + "200 OK", + "Content-Type: application/nostr+json\r\n", + body, + )]) + .await; + let client = reqwest::Client::new(); + let result = discover_admin_origin_at(&client, &format!("http://{addr}")) + .await + .unwrap(); + assert_eq!(result, None); +} + +#[tokio::test] +async fn discover_returns_none_when_advertised_value_invalid() { + // The relay advertises an invalid admin_api (non-loopback http). It is + // rejected by AdminOrigin::parse; discovery returns Ok(None) — no crash, + // no probe of the insecure endpoint. + let body = r#"{"admin_api":"http://admin.example.com"}"#; + let addr = serve_sequence(vec![( + "200 OK", + "Content-Type: application/nostr+json\r\n", + body, + )]) + .await; + let client = reqwest::Client::new(); + let result = discover_admin_origin_at(&client, &format!("http://{addr}")) + .await + .unwrap(); + assert_eq!(result, None); +} + +#[tokio::test] +async fn discover_errors_on_non_2xx() { + // A relay error (5xx) is a transport-level failure, not "no admin_api" — + // it must surface as Err rather than silently returning None. + let addr = serve_sequence(vec![("500 Internal Server Error", "", "")]).await; + let client = reqwest::Client::new(); + let result = discover_admin_origin_at(&client, &format!("http://{addr}")).await; + assert!( + result.is_err(), + "non-2xx must surface as Err; got {result:?}" + ); +} + +#[tokio::test] +async fn discover_requests_info_path_with_nostr_accept_header() { + // The discovery fetch must hit `/info` with the NIP-11 Accept header — + // the same contract as relay_requires_membership. + let captured: Arc>> = Arc::new(std::sync::Mutex::new(Vec::new())); + let captured_bg = Arc::clone(&captured); + let body = r#"{"admin_api":"http://127.0.0.1:3000"}"#; + let addr = serve_sequence_inspect( + vec![("200 OK", "Content-Type: application/nostr+json\r\n", body)], + Some(Arc::new(move |_idx, bytes: &[u8]| { + captured_bg.lock().unwrap().extend_from_slice(bytes); + })), + ) + .await; + let client = reqwest::Client::new(); + let _ = discover_admin_origin_at(&client, &format!("http://{addr}")) + .await + .unwrap(); + let request = String::from_utf8_lossy(&captured.lock().unwrap()).to_string(); + assert!( + request.starts_with("GET /info "), + "discovery must GET /info; got: {:?}", + request.lines().next() + ); + assert!( + request + .to_ascii_lowercase() + .contains("accept: application/nostr+json"), + "discovery must send the NIP-11 Accept header" + ); +} diff --git a/desktop/src-tauri/src/commands/admin/origin.rs b/desktop/src-tauri/src/commands/admin/origin.rs new file mode 100644 index 00000000000..643e93097c7 --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/origin.rs @@ -0,0 +1,271 @@ +//! `AdminOrigin` — a validated canonical admin console URL origin. +//! +//! An `AdminOrigin` holds exactly `scheme://host[:port]` and nothing else. +//! The webview supplies a raw URL string; this type validates and normalises +//! it before any downstream code can use it to construct request URLs. +//! +//! # Accepted inputs +//! - `https://host` → `https://host` +//! - `https://host:8443` → `https://host:8443` +//! - `http://localhost` → `http://localhost` +//! - `http://localhost:3000` → `http://localhost:3000` +//! - `http://127.0.0.1` → `http://127.0.0.1` +//! - `http://[::1]` → `http://[::1]` +//! +//! # Rejected inputs +//! - Any URL with `http://` to a non-loopback host +//! - Any URL with credentials (`user:pass@`) +//! - Any URL with a non-root path (`/admin`, `/api`) +//! - Any URL with a query string (`?foo=bar`) +//! - Any URL with a fragment (`#section`) +//! - Unknown or unsupported schemes (`ftp://`, `ws://`) + +use super::routes::{AdminQuery, AdminRoute}; + +/// A validated canonical admin console origin: `scheme://host[:port]`. +/// +/// Constructed only through `AdminOrigin::parse`; the inner string is +/// guaranteed to be a valid canonical origin. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AdminOrigin(String); + +impl AdminOrigin { + /// Parse and validate an operator-supplied URL into a canonical origin. + /// + /// Strips path, query, and fragment. Returns `Err` with a human-readable + /// message for any disallowed form. + pub fn parse(raw: &str) -> Result { + let parsed = + url::Url::parse(raw).map_err(|_| format!("invalid admin console URL: {raw:?}"))?; + + // Reject credentials. + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err("admin console URL must not contain credentials".to_string()); + } + + // Reject non-root path, query, and fragment. + let path = parsed.path(); + if path != "/" && !path.is_empty() { + return Err(format!( + "admin console URL must be an origin only (no path); got {path:?}" + )); + } + if parsed.query().is_some() { + return Err("admin console URL must not contain a query string".to_string()); + } + if parsed.fragment().is_some() { + return Err("admin console URL must not contain a fragment".to_string()); + } + + let host = parsed + .host_str() + .ok_or_else(|| "admin console URL has no host".to_string())?; + + let is_loopback = is_loopback_host(host); + + match parsed.scheme() { + "https" => { + // https is allowed for any host, including loopback (dev with TLS). + } + "http" => { + if !is_loopback { + return Err(format!( + "admin console URL must use HTTPS for non-loopback host {host:?}" + )); + } + } + other => { + return Err(format!( + "admin console URL scheme must be https (or http for loopback); got {other:?}" + )); + } + } + + // Build the canonical origin: scheme + "://" + host + optional :port. + let canonical = match parsed.port() { + Some(port) => format!("{}://{}:{}", parsed.scheme(), host, port), + None => format!("{}://{}", parsed.scheme(), host), + }; + + Ok(AdminOrigin(canonical)) + } + + /// The canonical origin string, e.g. `https://admin.example.com`. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Build the full request URL for `route` with `query`. + pub fn route_url(&self, route: &AdminRoute, query: &AdminQuery) -> String { + let path = route.path(); + let qs = query.to_query_string(); + if qs.is_empty() { + format!("{}/api/admin/v1{path}", self.0) + } else { + format!("{}/api/admin/v1{path}?{qs}", self.0) + } + } +} + +/// Returns true when `host` is a loopback address (`localhost`, `127.x.x.x`, +/// `[::1]`). This mirrors `media_download.rs`'s localhost carve-out. +fn is_loopback_host(host: &str) -> bool { + host == "localhost" + || host == "[::1]" + || host + .parse::() + .is_ok_and(|ip| ip.is_loopback()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Valid inputs ────────────────────────────────────────────────────────── + + #[test] + fn https_host_accepted() { + let o = AdminOrigin::parse("https://admin.example.com").unwrap(); + assert_eq!(o.as_str(), "https://admin.example.com"); + } + + #[test] + fn https_host_port_accepted() { + let o = AdminOrigin::parse("https://admin.example.com:8443").unwrap(); + assert_eq!(o.as_str(), "https://admin.example.com:8443"); + } + + #[test] + fn https_trailing_slash_stripped() { + // url::Url always parses "/" as the path for scheme+host-only URLs. + let o = AdminOrigin::parse("https://admin.example.com/").unwrap(); + assert_eq!(o.as_str(), "https://admin.example.com"); + } + + #[test] + fn http_localhost_accepted() { + let o = AdminOrigin::parse("http://localhost").unwrap(); + assert_eq!(o.as_str(), "http://localhost"); + } + + #[test] + fn http_localhost_port_accepted() { + let o = AdminOrigin::parse("http://localhost:3000").unwrap(); + assert_eq!(o.as_str(), "http://localhost:3000"); + } + + #[test] + fn http_127_accepted() { + let o = AdminOrigin::parse("http://127.0.0.1").unwrap(); + assert_eq!(o.as_str(), "http://127.0.0.1"); + } + + #[test] + fn http_ipv6_loopback_accepted() { + let o = AdminOrigin::parse("http://[::1]:3000").unwrap(); + assert_eq!(o.as_str(), "http://[::1]:3000"); + } + + // ── Invalid inputs ──────────────────────────────────────────────────────── + + #[test] + fn http_non_loopback_rejected() { + assert!(AdminOrigin::parse("http://admin.example.com").is_err()); + } + + #[test] + fn ftp_scheme_rejected() { + assert!(AdminOrigin::parse("ftp://admin.example.com").is_err()); + } + + #[test] + fn credentials_rejected() { + assert!(AdminOrigin::parse("https://user:pass@admin.example.com").is_err()); + } + + #[test] + fn path_rejected() { + assert!(AdminOrigin::parse("https://admin.example.com/api").is_err()); + } + + #[test] + fn query_rejected() { + assert!(AdminOrigin::parse("https://admin.example.com?foo=bar").is_err()); + } + + #[test] + fn fragment_rejected() { + assert!(AdminOrigin::parse("https://admin.example.com#section").is_err()); + } + + #[test] + fn garbage_rejected() { + assert!(AdminOrigin::parse("not a url").is_err()); + } + + // ── route_url builds correct URLs ───────────────────────────────────────── + + #[test] + fn route_url_reports_list_no_query() { + let o = AdminOrigin::parse("https://admin.example.com").unwrap(); + let url = o.route_url(&AdminRoute::ReportsList, &AdminQuery::default()); + assert_eq!(url, "https://admin.example.com/api/admin/v1/reports"); + } + + #[test] + fn route_url_report_detail() { + let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); + let o = AdminOrigin::parse("https://admin.example.com").unwrap(); + let url = o.route_url(&AdminRoute::ReportDetail { id }, &AdminQuery::default()); + assert_eq!( + url, + "https://admin.example.com/api/admin/v1/reports/00000000-0000-0000-0000-000000000001" + ); + } + + #[test] + fn route_url_feedback_attachment() { + let o = AdminOrigin::parse("https://admin.example.com").unwrap(); + let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(); + let sha256 = + crate::commands::admin::routes::AttachmentHash::parse(&"ab".repeat(32)).unwrap(); + let url = o.route_url( + &AdminRoute::FeedbackAttachment { id, sha256 }, + &AdminQuery::default(), + ); + assert!(url.contains("/api/admin/v1/feedback/")); + assert!(url.contains("/attachments/")); + } + + // ── Host case pin test ──────────────────────────────────────────────────── + // + // The `url` crate (per the URL Standard) lowercases ASCII hostnames during + // parsing. `AdminOrigin` preserves whatever the URL Standard produces — + // which for ASCII hostnames is always lowercase. This matches the relay's + // requirement that the admin console URL's host equals `BUZZ_ADMIN_HOST` + // byte-for-byte: since the URL parser always lowercases, operators must + // configure `BUZZ_ADMIN_HOST` in lowercase as well. + // + // A relay-side normalization chore (separate PR) would make `BUZZ_ADMIN_HOST` + // lowercase on startup, eliminating the footgun entirely. + #[test] + fn host_case_preserved_as_supplied() { + // Lowercase input stays lowercase. + let lower = AdminOrigin::parse("https://admin.example.com").unwrap(); + assert_eq!(lower.as_str(), "https://admin.example.com"); + + // The URL Standard normalises ASCII hostnames to lowercase — so "Admin.Example.Com" + // becomes "admin.example.com" after parsing. Both inputs produce the same + // canonical origin. Operators must therefore use lowercase in BUZZ_ADMIN_HOST. + let from_mixed = AdminOrigin::parse("https://Admin.Example.Com").unwrap(); + assert_eq!( + from_mixed.as_str(), + "https://admin.example.com", + "url::Url lowercases ASCII hostnames; canonical origin is always lowercase" + ); + + // Consequently the two parsed origins ARE equal — they produce identical + // NIP-98 u-tag values and both match a lowercase BUZZ_ADMIN_HOST. + assert_eq!(lower.as_str(), from_mixed.as_str()); + } +} diff --git a/desktop/src-tauri/src/commands/admin/routes.rs b/desktop/src-tauri/src/commands/admin/routes.rs new file mode 100644 index 00000000000..d0c4a299e24 --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/routes.rs @@ -0,0 +1,341 @@ +//! Closed route enum and typed query parameters for the admin API. +//! +//! No IPC surface accepts an arbitrary path; every URL is constructed here +//! from a typed route and typed query parameters. IDs are carried as `Uuid` +//! values so path injection is structurally impossible; the attachment hash is +//! validated to match the relay's exact lowercase-hex-only grammar before a +//! route is constructed. + +/// A validated lowercase 64-hex SHA-256 hash suitable for use as an attachment +/// path segment. Constructed only through [`AttachmentHash::parse`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AttachmentHash(String); + +impl AttachmentHash { + /// Parse `raw` as a lowercase 64-hex SHA-256. Returns `Err` for any input + /// that isn't exactly 64 lowercase hex digits, including uppercase A-F (the + /// relay stores lowercase and returns 404 on uppercase). + pub fn parse(raw: &str) -> Result { + if raw.len() != 64 { + return Err(format!( + "attachment hash must be exactly 64 hex characters; got {} characters", + raw.len() + )); + } + if !raw.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) { + return Err("attachment hash must be lowercase hex only (0-9, a-f); \ + uppercase is rejected — the relay stores lowercase and returns 404 otherwise" + .to_string()); + } + Ok(AttachmentHash(raw.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// The routes exposed by `/api/admin/v1`. +/// +/// IDs are typed `Uuid` — path injection via slash, `..`, `?`, `#`, or +/// percent-escapes is structurally impossible. The attachment hash is an +/// `AttachmentHash`, enforcing exact lowercase-hex grammar. Operator pubkeys +/// are validated hex strings. +#[derive(Debug)] +pub enum AdminRoute { + /// Auth-mode/role/capability discovery. Requires no DB and returns role + /// `null` in token/disabled modes. + Probe, + ReportsList, + ReportDetail { + id: uuid::Uuid, + }, + ReportResolve { + id: uuid::Uuid, + }, + ReportReopen { + id: uuid::Uuid, + }, + ReportCancel { + id: uuid::Uuid, + }, + FeedbackList, + FeedbackDetail { + id: uuid::Uuid, + }, + FeedbackAttachment { + id: uuid::Uuid, + sha256: AttachmentHash, + }, + FeedbackPatch { + id: uuid::Uuid, + }, + OperatorsList, + OperatorPut { + pubkey: HexPubkey, + }, + OperatorDelete { + pubkey: HexPubkey, + }, +} + +/// A validated 64 lowercase-hex character pubkey for use as a URL path segment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HexPubkey(String); + +impl HexPubkey { + /// Parse `raw` as a 64-character lowercase hex pubkey. + pub fn parse(raw: &str) -> Result { + if raw.len() != 64 { + return Err(format!( + "pubkey 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()); + } + Ok(HexPubkey(raw.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl AdminRoute { + /// Return the URL path component (not including the `/api/admin/v1` prefix). + pub fn path(&self) -> String { + match self { + AdminRoute::Probe => "/probe".to_string(), + AdminRoute::ReportsList => "/reports".to_string(), + AdminRoute::ReportDetail { id } => format!("/reports/{id}"), + AdminRoute::ReportResolve { id } => format!("/reports/{id}/resolve"), + AdminRoute::ReportReopen { id } => format!("/reports/{id}/reopen"), + AdminRoute::ReportCancel { id } => format!("/reports/{id}/cancel"), + AdminRoute::FeedbackList => "/feedback".to_string(), + AdminRoute::FeedbackDetail { id } => format!("/feedback/{id}"), + AdminRoute::FeedbackAttachment { id, sha256 } => { + format!("/feedback/{id}/attachments/{}", sha256.as_str()) + } + AdminRoute::FeedbackPatch { id } => format!("/feedback/{id}"), + AdminRoute::OperatorsList => "/operators".to_string(), + AdminRoute::OperatorPut { pubkey } => format!("/operators/{}", pubkey.as_str()), + AdminRoute::OperatorDelete { pubkey } => format!("/operators/{}", pubkey.as_str()), + } + } +} + +/// Optional query parameters for the reports-list endpoint. +/// +/// All fields are `Option` so the struct can be constructed with only +/// the fields the caller cares about; `to_query_string` omits `None` fields. +#[derive(Debug, Default)] +pub struct AdminQuery { + pub community_id: Option, + pub status: Option, + pub report_type: Option, + pub target_kind: Option, + pub after: Option, + pub before: Option, + pub limit: Option, +} + +impl AdminQuery { + /// Serialise to a URL query string (no leading `?`). Returns an empty + /// string when all fields are `None`. + pub fn to_query_string(&self) -> String { + let mut parts: Vec = Vec::new(); + if let Some(v) = &self.community_id { + parts.push(format!("communityId={}", urlencoded(v))); + } + if let Some(v) = &self.status { + parts.push(format!("status={}", urlencoded(v))); + } + if let Some(v) = &self.report_type { + parts.push(format!("reportType={}", urlencoded(v))); + } + if let Some(v) = &self.target_kind { + parts.push(format!("targetKind={}", urlencoded(v))); + } + if let Some(v) = &self.after { + parts.push(format!("after={}", urlencoded(v))); + } + if let Some(v) = &self.before { + parts.push(format!("before={}", urlencoded(v))); + } + if let Some(v) = &self.limit { + parts.push(format!("limit={v}")); + } + parts.join("&") + } +} + +/// Percent-encode a query parameter value, matching `url::form_urlencoded`. +fn urlencoded(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── AttachmentHash validation ───────────────────────────────────────────── + + #[test] + fn attachment_hash_valid_lowercase_hex() { + let h = AttachmentHash::parse(&"a".repeat(64)).unwrap(); + assert_eq!(h.as_str(), "a".repeat(64)); + } + + #[test] + fn attachment_hash_rejects_too_short() { + assert!(AttachmentHash::parse(&"a".repeat(63)).is_err()); + } + + #[test] + fn attachment_hash_rejects_too_long() { + assert!(AttachmentHash::parse(&"a".repeat(65)).is_err()); + } + + #[test] + fn attachment_hash_rejects_uppercase() { + // Uppercase passes is_ascii_hexdigit() but the relay returns 404 for it. + // AttachmentHash::parse must reject uppercase. + assert!(AttachmentHash::parse(&"A".repeat(64)).is_err()); + let mixed = format!("{}A{}", "a".repeat(32), "a".repeat(31)); + assert!(AttachmentHash::parse(&mixed).is_err()); + } + + #[test] + fn attachment_hash_rejects_non_hex_chars() { + // 'g' is not a hex digit. + assert!(AttachmentHash::parse(&"g".repeat(64)).is_err()); + } + + #[test] + fn attachment_hash_rejects_slash() { + let s = format!("{}/{}", "a".repeat(32), "a".repeat(31)); + assert!(AttachmentHash::parse(&s).is_err()); + } + + #[test] + fn attachment_hash_rejects_dot_dot() { + let s = format!("{}..{}", "a".repeat(31), "a".repeat(31)); + assert!(AttachmentHash::parse(&s).is_err()); + } + + #[test] + fn attachment_hash_rejects_percent_escape() { + // URL-encoded slash would be %2F — 3 chars, must fail length check too. + assert!(AttachmentHash::parse("%2F").is_err()); + // But also reject any % in a 64-char input. + let s = format!("{}%2{}", "a".repeat(31), "a".repeat(31)); + assert!(AttachmentHash::parse(&s).is_err()); + } + + #[test] + fn attachment_hash_rejects_query_fragment() { + let s = format!("{}?{}", "a".repeat(32), "a".repeat(31)); + assert!(AttachmentHash::parse(&s).is_err()); + let s2 = format!("{}#{}", "a".repeat(32), "a".repeat(31)); + assert!(AttachmentHash::parse(&s2).is_err()); + } + + // ── AdminRoute::path ───────────────────────────────────────────────────── + + #[test] + fn reports_list_path() { + assert_eq!(AdminRoute::ReportsList.path(), "/reports"); + } + + #[test] + fn probe_path() { + assert_eq!(AdminRoute::Probe.path(), "/probe"); + } + + #[test] + fn report_detail_path() { + let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); + assert_eq!( + AdminRoute::ReportDetail { id }.path(), + "/reports/00000000-0000-0000-0000-000000000001" + ); + } + + #[test] + fn report_reopen_path() { + let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(); + assert_eq!( + AdminRoute::ReportReopen { id }.path(), + "/reports/00000000-0000-0000-0000-000000000003/reopen" + ); + } + + #[test] + fn report_cancel_path() { + let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000004").unwrap(); + assert_eq!( + AdminRoute::ReportCancel { id }.path(), + "/reports/00000000-0000-0000-0000-000000000004/cancel" + ); + } + + #[test] + fn feedback_attachment_path() { + let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(); + let hash = AttachmentHash::parse(&"ab".repeat(32)).unwrap(); + let path = AdminRoute::FeedbackAttachment { + id, + sha256: hash.clone(), + } + .path(); + assert_eq!( + path, + format!( + "/feedback/00000000-0000-0000-0000-000000000002/attachments/{}", + hash.as_str() + ) + ); + } + + // ── AdminQuery ─────────────────────────────────────────────────────────── + + #[test] + fn query_empty_produces_no_string() { + assert_eq!(AdminQuery::default().to_query_string(), ""); + } + + #[test] + fn query_limit_only() { + let q = AdminQuery { + limit: Some(50), + ..Default::default() + }; + assert_eq!(q.to_query_string(), "limit=50"); + } + + #[test] + fn query_multiple_params() { + let q = AdminQuery { + status: Some("open".to_string()), + limit: Some(100), + ..Default::default() + }; + let qs = q.to_query_string(); + assert!(qs.contains("status=open"), "expected status in {qs}"); + assert!(qs.contains("limit=100"), "expected limit in {qs}"); + } + + #[test] + fn query_value_is_percent_encoded() { + let q = AdminQuery { + status: Some("open&active".to_string()), + ..Default::default() + }; + let qs = q.to_query_string(); + // & in value must be encoded so it doesn't split the query. + assert!(!qs.contains("status=open&active"), "bare & leaked: {qs}"); + assert!(qs.contains("status="), "status key missing: {qs}"); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 52473716465..d406d7b8974 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod admin; mod agent_access; mod agent_auth; mod agent_config; @@ -67,6 +68,7 @@ mod window_vibrancy; mod workflows; mod workspace; +pub use admin::*; pub use agent_access::*; pub use agent_auth::*; pub use agent_config::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7aa954ce8e6..8a730f9b93b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -194,95 +194,10 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()); - // The global-shortcut plugin is omitted from test builds: linking it into - // the lib-test binary makes it fail to load on Windows (STATUS_ENTRYPOINT_NOT_FOUND) before any test runs. + // The global-shortcut plugin is omitted from test builds — see + // `ptt_shortcut::build_plugin` for the full rationale and implementation. #[cfg(not(test))] - let builder = builder.plugin({ - use tauri_plugin_global_shortcut::ShortcutState; - - // Generation counter for the release delay task. Incremented on - // every press — a delayed release only fires if the generation - // hasn't changed (i.e. no new press happened during the delay). - // This prevents press→release→press within 200 ms from having - // the first release clobber the second press. - let ptt_press_gen = Arc::new(std::sync::atomic::AtomicU64::new(0)); - - tauri_plugin_global_shortcut::Builder::new() - .with_handler(move |app, _shortcut, event| { - let state = match app.try_state::() { - Some(s) => s, - None => return, - }; - - // Only act if a huddle is active and mode is PTT. - let (is_ptt_mode, is_active) = match state.huddle_state.lock() { - Ok(hs) => ( - hs.voice_input_mode == huddle::VoiceInputMode::PushToTalk, - matches!( - hs.phase, - huddle::HuddlePhase::Connected | huddle::HuddlePhase::Active - ), - ), - Err(_) => return, - }; - - if !is_ptt_mode || !is_active { - return; - } - - match event.state { - ShortcutState::Pressed => { - // Bump generation — invalidates any pending release delay. - ptt_press_gen.fetch_add(1, std::sync::atomic::Ordering::Release); - - if let Ok(hs) = state.huddle_state.lock() { - hs.ptt_active - .store(true, std::sync::atomic::Ordering::Release); - // Only cancel TTS if it's actually playing — avoids - // a stale cancel flag that drops the next queued message. - if hs.tts_active.load(std::sync::atomic::Ordering::Acquire) { - hs.tts_cancel - .store(true, std::sync::atomic::Ordering::Release); - } - } - // Emit ptt-state=true to the frontend. - // The React side plays the press audio cue on this event - // (Web Audio API via HuddleContext). Rust-side rodio audio - // was considered but rejected: the rodio OutputStream must - // outlive the handler and sharing it across the shortcut - // closure adds lifecycle complexity for marginal gain. - // The React implementation is sufficient and simpler. - let _ = app.emit("ptt-state", true); - } - ShortcutState::Released => { - // Capture generation at release time. - let gen_at_release = - ptt_press_gen.load(std::sync::atomic::Ordering::Acquire); - let gen_arc = Arc::clone(&ptt_press_gen); - let app_handle = app.clone(); - // 200 ms release delay — captures the tail of the utterance. - // Only applies if no new press happened during the delay. - tauri::async_runtime::spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(200)).await; - // Check generation — if it changed, a new press arrived. - if gen_arc.load(std::sync::atomic::Ordering::Acquire) != gen_at_release - { - return; // Superseded by a new press. - } - if let Some(state) = app_handle.try_state::() { - if let Ok(hs) = state.huddle_state.lock() { - hs.ptt_active - .store(false, std::sync::atomic::Ordering::Release); - } - } - // Emit ptt-state=false — React plays the release audio cue. - let _ = app_handle.emit("ptt-state", false); - }); - } - } - }) - .build() - }); + let builder = builder.plugin(ptt_shortcut::build_plugin()); // Register the updater only in configured release builds; omit it locally. #[cfg(buzz_updater_enabled)] @@ -315,6 +230,10 @@ pub fn run() { macos_notifications::init(&app_handle)?; } + // Initialise the no-redirect admin HTTP client singleton before any + // admin command can be invoked. Must run before setup completes. + commands::admin::client::init_admin_client(); + // ── Phase 2: boot-time sentinel wipe ────────────────────────────── // Must run before migrations and identity resolution so the wipe // completes atomically on crash recovery. @@ -914,6 +833,23 @@ pub fn run() { tray_menu::take_tray_actions, #[cfg(target_os = "macos")] tray_menu::update_tray_agent_activity, + // ── Desktop admin surface ──────────────────────────────────────── + admin_probe, + admin_list_reports, + admin_get_report, + admin_list_feedback, + admin_get_feedback, + admin_fetch_feedback_attachment, + admin_resolve_report, + admin_reopen_report, + admin_cancel_report, + admin_patch_feedback, + admin_list_operators, + admin_put_operator, + admin_delete_operator, + get_admin_origin, + set_admin_origin, + admin_discover_origin, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/desktop/src-tauri/src/ptt_shortcut.rs b/desktop/src-tauri/src/ptt_shortcut.rs index a80af67a4d9..50495d62609 100644 --- a/desktop/src-tauri/src/ptt_shortcut.rs +++ b/desktop/src-tauri/src/ptt_shortcut.rs @@ -9,6 +9,101 @@ use crate::huddle::HuddleState; #[cfg(not(test))] use crate::huddle::{HuddlePhase, VoiceInputMode}; +/// Build the global-shortcut plugin with the PTT press/release handler. +/// +/// Extracted from `run()` to keep `lib.rs` under the line-count ratchet. +/// The plugin is omitted from test builds — linking it into the lib-test +/// binary makes it fail to load on Windows (STATUS_ENTRYPOINT_NOT_FOUND) +/// before any test runs. The call site is therefore gated with +/// `#[cfg(not(test))]`. +/// +/// The `ptt_press_gen` counter prevents press→release→press within 200 ms +/// from having the first release clobber the second press: each press bumps +/// the generation, and the delayed release task bails if the generation has +/// changed when it wakes. +#[cfg(not(test))] +pub fn build_plugin() -> impl tauri::plugin::Plugin { + use std::sync::{atomic::AtomicU64, Arc}; + use tauri::{Emitter, Manager}; + use tauri_plugin_global_shortcut::ShortcutState; + + // Generation counter for the release delay task. Incremented on every + // press — a delayed release only fires if the generation hasn't changed + // (i.e. no new press happened during the delay). + let ptt_press_gen = Arc::new(AtomicU64::new(0)); + + tauri_plugin_global_shortcut::Builder::new() + .with_handler(move |app, _shortcut, event| { + let state = match app.try_state::() { + Some(s) => s, + None => return, + }; + + // Only act if a huddle is active and mode is PTT. + let (is_ptt_mode, is_active) = match state.huddle_state.lock() { + Ok(hs) => ( + hs.voice_input_mode == VoiceInputMode::PushToTalk, + matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active), + ), + Err(_) => return, + }; + + if !is_ptt_mode || !is_active { + return; + } + + match event.state { + ShortcutState::Pressed => { + // Bump generation — invalidates any pending release delay. + ptt_press_gen.fetch_add(1, std::sync::atomic::Ordering::Release); + + if let Ok(hs) = state.huddle_state.lock() { + hs.ptt_active + .store(true, std::sync::atomic::Ordering::Release); + // Only cancel TTS if it's actually playing — avoids + // a stale cancel flag that drops the next queued message. + if hs.tts_active.load(std::sync::atomic::Ordering::Acquire) { + hs.tts_cancel + .store(true, std::sync::atomic::Ordering::Release); + } + } + // Emit ptt-state=true to the frontend. + // The React side plays the press audio cue on this event + // (Web Audio API via HuddleContext). Rust-side rodio audio + // was considered but rejected: the rodio OutputStream must + // outlive the handler and sharing it across the shortcut + // closure adds lifecycle complexity for marginal gain. + // The React implementation is sufficient and simpler. + let _ = app.emit("ptt-state", true); + } + ShortcutState::Released => { + // Capture generation at release time. + let gen_at_release = ptt_press_gen.load(std::sync::atomic::Ordering::Acquire); + let gen_arc = Arc::clone(&ptt_press_gen); + let app_handle = app.clone(); + // 200 ms release delay — captures the tail of the utterance. + // Only applies if no new press happened during the delay. + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + // Check generation — if it changed, a new press arrived. + if gen_arc.load(std::sync::atomic::Ordering::Acquire) != gen_at_release { + return; // Superseded by a new press. + } + if let Some(state) = app_handle.try_state::() { + if let Ok(hs) = state.huddle_state.lock() { + hs.ptt_active + .store(false, std::sync::atomic::Ordering::Release); + } + } + // Emit ptt-state=false — React plays the release audio cue. + let _ = app_handle.emit("ptt-state", false); + }); + } + } + }) + .build() +} + /// Whether the PTT shortcut should currently be reserved with the OS. #[cfg(not(test))] fn should_register(hs: &HuddleState) -> bool { diff --git a/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx b/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx new file mode 100644 index 00000000000..bef69579966 --- /dev/null +++ b/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx @@ -0,0 +1,472 @@ +/** + * Feedback tab — shows the deployment-wide product feedback queue with + * optional image attachment viewer and status triage controls. + * + * The attachment viewer uses a per-load generation fence to discard results + * from superseded loads on identity/origin change or unmount. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { AlertCircle, ChevronLeft, Download, LoaderCircle } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/shared/ui/button"; +import { Badge } from "@/shared/ui/badge"; +import { cn } from "@/shared/lib/cn"; +import { + fetchAdminAttachmentBlobUrl, + getAdminFeedback, + listAdminFeedback, + patchAdminFeedback, + type AdminAttachmentErrorCode, + type AdminFeedbackDto, + type AdminFeedbackStatus, + type AdminFeedbackSummaryDto, +} from "./api"; +import { + type AsyncState, + type AttachmentMeta, + adminErrorMessage, + CommunityGroupedList, + DetailRow, + ErrorMessage, + LoadingSpinner, + formatTimestamp, + parseImetaAttachments, + useAsyncLoad, +} from "./AdminConsolePanelHelpers"; + +// ── Feedback tab ────────────────────────────────────────────────────────── + +export function FeedbackTab({ + origin, + pubkey, + generation, +}: { + origin: string; + pubkey: string; + generation: number; +}) { + const [selectedId, setSelectedId] = useState(null); + // List refresh fence: bumped when a feedback status change completes in the + // detail view, so returning to the list shows fresh status without a tab + // switch. + const [listGen, setListGen] = useState(0); + + const listState: AsyncState = useAsyncLoad( + () => listAdminFeedback(origin), + [origin, pubkey], + generation + listGen, + ); + + if (selectedId) { + return ( + setSelectedId(null)} + origin={origin} + pubkey={pubkey} + generation={generation} + onMutated={() => setListGen((g) => g + 1)} + /> + ); + } + + if (listState.status === "loading") return ; + if (listState.status === "error") { + return ; + } + if (listState.status !== "ok") return null; + + const items = listState.data; + if (!Array.isArray(items) || items.length === 0) { + return

No feedback found.

; + } + + return ( + { + const id = item.id; + const text = item.bodySummary.slice(0, 120); + const receivedAt = item.receivedAt; + const status = item.status; + return ( +
  • + +
  • + ); + }} + /> + ); +} + +// ── Attachment viewer ───────────────────────────────────────────────────── + +function AttachmentViewer({ + origin, + pubkey, + feedbackId, + attachment, + panelGeneration, +}: { + origin: string; + pubkey: string; + feedbackId: string; + attachment: AttachmentMeta; + /** Generation from the parent panel — when this changes the attachment + * context has changed and any in-flight load result is stale. */ + panelGeneration: number; +}) { + const [blobUrl, setBlobUrl] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const blobUrlRef = useRef(null); + // Per-load generation: incremented when a new load starts AND in cleanup so + // that unmount or panelGeneration change invalidates any in-flight load. + const loadGenRef = useRef(0); + // Keep current origin/pubkey in refs so the callback can compare against + // the rendered-at-call-time values without capturing stale closure copies. + const originRef = useRef(origin); + const pubkeyRef = useRef(pubkey); + originRef.current = origin; + pubkeyRef.current = pubkey; + + // On panelGeneration change (identity/origin switch) or unmount: + // invalidate any in-flight load and revoke the cached blob URL. + // biome-ignore lint/correctness/useExhaustiveDependencies: panelGeneration is a prop that drives cleanup re-registration; the cleanup body mutates refs, not reactive state + useEffect(() => { + return () => { + // Increment generation so any in-flight native callback sees a mismatch. + loadGenRef.current += 1; + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + }; + }, [panelGeneration]); + + const load = useCallback(async () => { + // Capture snapshot of context at the moment this load starts. + const thisGen = ++loadGenRef.current; + const thisOrigin = origin; + const thisPubkey = pubkey; + + setLoading(true); + setError(null); + try { + const url = await fetchAdminAttachmentBlobUrl( + origin, + feedbackId, + attachment.sha256, + attachment.mime, + attachment.size, + ); + + // Discard if a newer load started, the component was unmounted/context + // changed (loadGenRef incremented in cleanup), or origin/pubkey differ. + if ( + thisGen !== loadGenRef.current || + thisOrigin !== originRef.current || + thisPubkey !== pubkeyRef.current + ) { + URL.revokeObjectURL(url); + return; + } + + // Revoke any previous blob before replacing. + if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = url; + setBlobUrl(url); + } catch (e) { + if (thisGen !== loadGenRef.current) return; + setError( + typeof e === "string" ? (e as AdminAttachmentErrorCode) : String(e), + ); + } finally { + if (thisGen === loadGenRef.current) setLoading(false); + } + }, [ + origin, + pubkey, + feedbackId, + attachment.sha256, + attachment.mime, + attachment.size, + ]); + + // Auto-load image/* attachments immediately on mount — no button click needed. + // Routes through the same `load` callback (generation fence, SSRF guard, + // revoke-on-cleanup), so the existing blob-leak tests remain valid and cover + // the auto-load path. + // biome-ignore lint/correctness/useExhaustiveDependencies: load is a stable useCallback; attachment.mime is a mount-time constant — auto-load fires once per mount + useEffect(() => { + if (attachment.mime.startsWith("image/")) { + void load(); + } + }, []); // Empty: fires once on mount; identity boundary and generation fence handle context changes. + + if (error) { + const friendlyError: Record = { + admin_attachment_too_large: "Attachment exceeds the 10 MiB desktop cap.", + admin_attachment_mime_mismatch: + "Attachment MIME type does not match the imeta record.", + admin_attachment_size_mismatch: + "Attachment byte count does not match the imeta record.", + admin_attachment_network_error: "Network error fetching attachment.", + }; + return ( +
    + + {friendlyError[error] ?? `Error: ${error}`} +
    + ); + } + + if (!blobUrl) { + // For image/* types the load is triggered automatically on mount. + // Show only a spinner while in-flight; the "View attachment" button is + // for non-image MIME types where the user opts in to loading. + if (attachment.mime.startsWith("image/") || loading) { + return ( +
    + + Loading… +
    + ); + } + return ( + + ); + } + + if (attachment.mime.startsWith("image/")) { + return ( + Feedback attachment + ); + } + + return ( + + + Download attachment ({attachment.mime}) + + ); +} + +// ── Feedback fields ─────────────────────────────────────────────────────── + +function FeedbackFields({ data }: { data: AdminFeedbackDto }) { + return ( +
    + + + + + + + + + +
    + ); +} + +// ── Feedback status control ─────────────────────────────────────────────── + +/** + * Status-control widget for a feedback entry. + * Lets operators/moderators triage feedback by marking it as + * `reviewed` or `archived` (or reverting to `new`). + */ +function FeedbackStatusControl({ + feedbackId, + currentStatus, + origin, + onStatusChanged, +}: { + feedbackId: string; + currentStatus: AdminFeedbackStatus | null; + origin: string; + onStatusChanged: (newStatus: AdminFeedbackStatus) => void; +}) { + const [isWorking, setIsWorking] = useState(false); + + const statuses: AdminFeedbackStatus[] = ["new", "reviewed", "archived"]; + + const handleStatusChange = async (newStatus: AdminFeedbackStatus) => { + if (newStatus === currentStatus) return; + setIsWorking(true); + try { + await patchAdminFeedback(origin, feedbackId, newStatus); + toast.success(`Feedback marked ${newStatus}`); + onStatusChanged(newStatus); + } catch (e) { + toast.error(adminErrorMessage(e)); + } finally { + setIsWorking(false); + } + }; + + return ( +
    + Status +
    + {statuses.map((s) => ( + + ))} +
    +
    + ); +} + +// ── Feedback detail ─────────────────────────────────────────────────────── + +export function FeedbackDetail({ + origin, + pubkey, + generation, + feedbackId, + onBack, + onMutated, +}: { + origin: string; + pubkey: string; + generation: number; + feedbackId: string; + onBack: () => void; + /** Called after a status change completes so the parent list can refetch. */ + onMutated: () => void; +}) { + // Local status state: initialized from server, updated on PATCH. + const [localStatus, setLocalStatus] = useState( + null, + ); + + const detailState: AsyncState = useAsyncLoad( + () => getAdminFeedback(origin, feedbackId), + [origin, pubkey, feedbackId], + generation, + ); + + // Sync localStatus from server data on load (but not on every re-render). + // `status` is a required wire field — read it directly, never default a + // missing value to "new" (that would misreport a reviewed/archived entry). + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — sync once when data arrives + useEffect(() => { + if (detailState.status === "ok") { + setLocalStatus(detailState.data.status); + } + }, [detailState.status === "ok"]); + + // Parse imeta attachment metadata from the relay's wire `tags: string[][]`. + // AdminFeedback is serialised camelCase by the relay (serde rename_all). + const attachments: AttachmentMeta[] = + detailState.status === "ok" + ? parseImetaAttachments(detailState.data.tags) + : []; + + return ( +
    + + {detailState.status === "loading" && } + {detailState.status === "error" && ( + + )} + {detailState.status === "ok" && ( + <> + + { + setLocalStatus(newStatus); + onMutated(); + }} + /> + {attachments.length > 0 && ( +
    +

    Attachments

    + {attachments.map((a) => ( + + ))} +
    + )} + + )} +
    + ); +} diff --git a/desktop/src/features/admin-console/AdminConsolePanel.tsx b/desktop/src/features/admin-console/AdminConsolePanel.tsx new file mode 100644 index 00000000000..8f2afd71abb --- /dev/null +++ b/desktop/src/features/admin-console/AdminConsolePanel.tsx @@ -0,0 +1,851 @@ +/** + * 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). + * + * 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 + * effect cleanup, ensuring stale results are discarded on arrival. + * + * Tauri invoke is not cancellable at the native layer, but the active-flag + * pattern ensures stale results never update visible state or create + * unreachable blob URLs. + * + * Sub-components live in adjacent files: + * - AdminConsolePanelHelpers.tsx — AsyncState, useAsyncLoad, formatTimestamp, + * DetailRow, LoadingSpinner, ErrorMessage, + * AttachmentMeta, parseImetaAttachments + * - AdminConsoleFeedbackTab.tsx — FeedbackTab, FeedbackDetail + * - AdminConsoleStaffingTab.tsx — StaffingTab + */ + +import { useEffect, useRef, useState } from "react"; +import { + ChevronLeft, + LoaderCircle, + MessageSquare, + Shield, + ShieldAlert, + Users, +} from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/shared/ui/button"; +import { Badge } from "@/shared/ui/badge"; +import { cn } from "@/shared/lib/cn"; +import { + getAdminReport, + listAdminReports, + cancelAdminReport, + reopenAdminReport, + resolveAdminReport, + type AdminPrincipalRole, + type AdminPrincipalSource, + type AdminReportAction, + type AdminReportDetailDto, + type AdminReportDto, +} from "./api"; +import { + DetailRow, + ErrorMessage, + LoadingSpinner, + CommunityGroupedList, + formatTimestamp, + useAsyncLoad, + adminErrorMessage, + preserveRequestIdOnError, +} from "./AdminConsolePanelHelpers"; +import { FeedbackTab } from "./AdminConsoleFeedbackTab"; +import { StaffingTab } from "./AdminConsoleStaffingTab"; + +export { + parseImetaAttachments, + type AttachmentMeta, +} from "./AdminConsolePanelHelpers"; + +// ── Status variant helper ───────────────────────────────────────────────── + +function statusVariant( + status: string, +): "default" | "secondary" | "destructive" | "outline" { + switch (status) { + case "open": + return "default"; + case "resolved": + return "secondary"; + case "dismissed": + return "outline"; + case "escalated": + return "secondary"; + case "processing": + return "secondary"; + case "pending": + return "secondary"; + case "enforcing": + return "secondary"; + case "succeeded": + return "secondary"; + case "failed": + return "destructive"; + case "cancelled": + return "outline"; + default: + return "outline"; + } +} + +// ── Action matrix helpers ───────────────────────────────────────────────── + +/** + * Return the allowed actions for a given target kind per the v4 frozen matrix. + * event: delete | kick | ban | timeout | dismiss | escalate + * pubkey: ban | timeout | dismiss | escalate + * blob: dismiss | escalate + */ +function allowedActionsForTargetKind(targetKind: string): AdminReportAction[] { + switch (targetKind.toLowerCase()) { + case "event": + return ["delete", "kick", "ban", "timeout", "dismiss", "escalate"]; + case "pubkey": + return ["ban", "timeout", "dismiss", "escalate"]; + case "blob": + return ["dismiss", "escalate"]; + default: + return ["dismiss", "escalate"]; + } +} + +/** Label for each action. */ +function actionLabel(action: AdminReportAction): string { + switch (action) { + case "delete": + return "Delete"; + case "kick": + return "Kick"; + case "ban": + return "Ban"; + case "timeout": + return "Timeout"; + case "dismiss": + return "Dismiss"; + case "escalate": + return "Escalate"; + } +} + +/** Variant for each action button. */ +function actionVariant( + action: AdminReportAction, +): "destructive" | "outline" | "secondary" { + switch (action) { + case "delete": + case "ban": + return "destructive"; + case "kick": + case "timeout": + return "outline"; + default: + return "secondary"; + } +} + +// ── Enforcement state block ─────────────────────────────────────────────── + +/** + * Inline enforcement-state block shown on `processing` reports and after a + * failed enforcement action. Shows the action record's state and offers cancel + * on a `failed` action. + * + * A `failed` action is always pre-mutation (the relay only records `failed` + * before the enforcement side effect lands), so it is always cancellable. + * Cancel is the only recovery path: it returns the report to `open` for a + * fresh resolution. There is no client-side "retry" — composing cancel + a new + * resolve would imply an atomicity the relay does not provide, leaving a window + * where the report is open with no explanation if the second call is lost. + * + * `pending`/`enforcing` actions are NOT cancellable over HTTP — the relay's + * recovery worker owns their convergence — so no button is offered there. + * A rejected cancel (409) is authoritative: the action already advanced or + * someone else cancelled it, so reload detail rather than retrying. + */ +function EnforcementStateBlock({ + activeAction, + origin, + reportId, + onActionComplete, +}: { + activeAction: NonNullable; + origin: string; + reportId: string; + onActionComplete: () => void; +}) { + const [isWorking, setIsWorking] = useState(false); + + const actionStatus = activeAction.status; + + // User-facing copy for each action state. + const stateLabel: Record = { + pending: "Enforcement pending…", + enforcing: "Enforcing…", + succeeded: "Enforcement succeeded", + failed: "Enforcement failed", + cancelled: "Enforcement cancelled", + }; + + const handleCancel = async () => { + setIsWorking(true); + try { + // Fence the cancel to the exact failed action the operator observed. On + // success the report returns to `open`; the detail reload then serves + // `activeAction: null` and re-exposes the resolve form for a fresh attempt. + await cancelAdminReport(origin, reportId, { + actionId: activeAction.id, + }); + toast.success("Enforcement cancelled — report reopened"); + onActionComplete(); + } catch (e) { + // A 409 means the action is no longer cancellable (already cancelled, + // superseded, or past the mutation point). Reload detail rather than + // retry — the toast is informational, the reload shows current state. + toast.error(`Cancel rejected: ${adminErrorMessage(e)}`); + onActionComplete(); + } finally { + setIsWorking(false); + } + }; + + return ( +
    +
    + {(actionStatus === "pending" || actionStatus === "enforcing") && ( + + )} + + {stateLabel[actionStatus] ?? actionStatus} + + + {activeAction.action} + +
    + {activeAction.errorMessage && ( +

    + {activeAction.errorMessage} +

    + )} + {actionStatus === "failed" && ( + + )} +
    + ); +} + +// ── Resolve report form ─────────────────────────────────────────────────── + +/** + * Resolution form — shown on open reports (not `processing`). Presents the + * action matrix for the report's target_kind, collects optional reason and + * (for timeout) expiration_secs, then calls the resolve endpoint. + * + * The form generates a `requestId` per submission attempt. On retry after a + * lost response, the caller should reuse the same `requestId` — this is + * handled by the retry path in `EnforcementStateBlock`. + */ +function ResolveReportForm({ + report, + origin, + onResolved, +}: { + report: AdminReportDto; + origin: string; + onResolved: () => void; +}) { + const [selectedAction, setSelectedAction] = + useState(null); + const [reason, setReason] = useState(""); + const [expirationSecs, setExpirationSecs] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + // Stable requestId per submission; regenerated on each new submit attempt. + const requestIdRef = useRef(null); + + // Kick removes the target from the report's associated channel, so the + // relay rejects it (400 invalid_action_for_target) when the report carries + // no channel. Suppress it client-side rather than offer a guaranteed failure. + const allowedActions = allowedActionsForTargetKind( + report.targetKind ?? "", + ).filter((a) => a !== "kick" || report.channelId != null); + + const handleSubmit = async () => { + if (!selectedAction) return; + setIsSubmitting(true); + + // Generate a fresh requestId for this submission attempt (v4 amendment 2). + if (!requestIdRef.current) { + requestIdRef.current = crypto.randomUUID(); + } + + try { + await resolveAdminReport(origin, report.id, { + action: selectedAction, + requestId: requestIdRef.current, + expirationSecs: + selectedAction === "timeout" && expirationSecs + ? Number(expirationSecs) + : undefined, + reason: reason.trim() || undefined, + }); + toast.success(`Report resolved: ${actionLabel(selectedAction)}`); + onResolved(); + } catch (e) { + // Preserve the requestId whenever the outcome is ambiguous (409, + // 5xx, a lost response, or a transport failure with no relay answer) + // so a retry reuses the same idempotency key and the relay dedupes. + // Reset only on a definitive pre-commit rejection (a non-409 4xx), where + // a corrected resubmission is a genuinely new command. + if (!preserveRequestIdOnError(e)) { + requestIdRef.current = null; + } + toast.error(adminErrorMessage(e)); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
    +

    + Resolve report +

    +
    + {allowedActions.map((action) => ( + + ))} +
    + + {selectedAction === "timeout" && ( +
    + + setExpirationSecs(e.target.value)} + placeholder="e.g. 3600" + type="number" + value={expirationSecs} + /> +
    + )} + +
    + setReason(e.target.value)} + placeholder="Reason (optional)" + type="text" + value={reason} + /> +
    + + {selectedAction && ( + + )} +
    + ); +} + +// ── Reopen report form ──────────────────────────────────────────────────── + +/** + * Reopen form — shown on terminal reports (`resolved` | `dismissed` | + * `escalated`). Moves the report back to `open` for re-triage. + * + * Reopen is re-triage only: it does NOT reverse any enforcement already taken + * (no un-ban, no un-timeout, no message restore). The copy states this + * explicitly, and more emphatically when the report carries an `actionId` + * (an enforcement action was applied while it was resolved). + * + * A `requestId` is generated per attempt and reused on retry so a lost + * response is idempotent, mirroring the resolve flow. A 409 (report not + * reopenable — e.g. it moved to `processing`) preserves the `requestId`. + */ +function ReopenReportForm({ + report, + origin, + onReopened, +}: { + report: AdminReportDto; + origin: string; + onReopened: () => void; +}) { + const [reason, setReason] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + // Stable requestId per attempt; reused on retry after a lost response. + const requestIdRef = useRef(null); + + // An enforcement action was applied while this report was resolved. + const wasEnforced = report.actionId != null; + + const handleSubmit = async () => { + setIsSubmitting(true); + + if (!requestIdRef.current) { + requestIdRef.current = crypto.randomUUID(); + } + + try { + await reopenAdminReport(origin, report.id, { + requestId: requestIdRef.current, + reason: reason.trim() || undefined, + }); + toast.success("Report reopened"); + onReopened(); + } catch (e) { + // Preserve the requestId on an ambiguous outcome (409, 5xx, lost + // response, or a transport failure with no relay answer) so a retry + // reuses the same idempotency key; reset only on a definitive pre-commit + // rejection (a non-409 4xx). + if (!preserveRequestIdOnError(e)) { + requestIdRef.current = null; + } + toast.error(adminErrorMessage(e)); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
    +
    +

    + Reopen report +

    +

    + Moves this report back to the open queue for re-triage.{" "} + {wasEnforced + ? "The enforcement action already taken is not reversed — reopening does not un-ban, un-timeout, or restore a deleted message." + : "Reopening does not reverse any enforcement action."} +

    +
    + + setReason(e.target.value)} + placeholder="Reason (optional)" + type="text" + value={reason} + /> + + +
    + ); +} + +// ── Reports tab ─────────────────────────────────────────────────────────── + +function ReportsTab({ + origin, + pubkey, + generation, +}: { + origin: string; + pubkey: string; + generation: number; +}) { + const [selectedId, setSelectedId] = useState(null); + // List refresh fence: bumped whenever a mutation completes in the detail + // view, so returning to the list shows fresh status without a tab switch. + const [listGen, setListGen] = useState(0); + + const listState = useAsyncLoad( + () => listAdminReports(origin), + [origin, pubkey], + generation + listGen, + ); + + if (selectedId) { + return ( + setSelectedId(null)} + onMutated={() => setListGen((g) => g + 1)} + /> + ); + } + + if (listState.status === "loading") { + return ; + } + if (listState.status === "error") { + return ; + } + if (listState.status !== "ok") return null; + + const reports = listState.data; + if (!Array.isArray(reports) || reports.length === 0) { + return

    No reports found.

    ; + } + + return ( + { + const id = report.id; + const summary = report.reportType || "Report"; + const status = report.status; + const isProcessing = status === "processing"; + return ( +
  • + {/* Processing rows stay navigable: the enforcement state (progress, + retry, cancel) lives inside the detail view, so disabling the row + would hide exactly the controls an operator needs while an action + is pending. Detail suppresses only the resolve form for a + non-open report. */} + +
  • + ); + }} + /> + ); +} + +function ReportFields({ data }: { data: AdminReportDetailDto }) { + const status = data.status ?? ""; + return ( +
    +
    + {status && {status}} + {data.reportType && {data.reportType}} +
    + + + + + + + + + + + + + + {data.message != null && ( +
    +

    + Reported message + {data.message.deletedAt != null && ( + (deleted) + )} +

    + + + +
    + )} +
    + ); +} + +function ReportDetail({ + origin, + pubkey, + generation, + reportId, + onBack, + onMutated, +}: { + origin: string; + pubkey: string; + generation: number; + reportId: string; + onBack: () => void; + /** Called after any mutation completes so the parent list can refetch. */ + onMutated: () => void; +}) { + // Resolution generation: bump to reload detail after an action completes. + const [resolveGen, setResolveGen] = useState(0); + + // Reload the detail AND signal the parent list on every completed mutation, + // so back-nav shows fresh status without the tab-switch workaround. + const handleMutated = () => { + setResolveGen((g) => g + 1); + onMutated(); + }; + + const detailState = useAsyncLoad( + () => getAdminReport(origin, reportId), + [origin, pubkey, reportId], + generation + resolveGen, + ); + + const data = detailState.status === "ok" ? detailState.data : null; + const isOpen = data?.status === "open"; + const isReopenable = + data?.status === "resolved" || + data?.status === "dismissed" || + data?.status === "escalated"; + const activeAction = data?.activeAction ?? null; + + return ( +
    + + {detailState.status === "loading" && } + {detailState.status === "error" && ( + + )} + {detailState.status === "ok" && ( + <> + + {/* Enforcement state / history. The detail LATERAL returns an action + whenever one governs the report: a live action (pending/enforcing) + or a cancellable failed action while processing, or a succeeded + action as executed-enforcement history on a terminal or reopened + report (honest history — a later dismissal/reopen does not + un-happen the ban that ran). Cancel is offered only on failed, + inside the block. A cancelled action never reaches this read. */} + {activeAction && ( + + )} + {/* Resolve form: shown for open reports. A reopened-after-enforcement + report is open yet carries a succeeded activeAction (history); + the form must still show so the operator can re-triage — the + enforcement block above renders that history alongside it. Only a + live/failed action keeps the report `processing` (not open), so + `isOpen` alone never surfaces the form on an in-flight action. */} + {isOpen && ( + + )} + {/* Reopen form: only for terminal (resolved/dismissed/escalated) reports */} + {isReopenable && ( + + )} + + )} +
    + ); +} + +// ── Tab bar ─────────────────────────────────────────────────────────────── + +type Tab = "reports" | "feedback" | "staffing"; + +function TabBar({ + activeTab, + onSelect, + showStaffing, +}: { + activeTab: Tab; + onSelect: (tab: Tab) => void; + showStaffing: boolean; +}) { + const allTabs: Array<{ + value: Tab; + label: string; + Icon: React.ComponentType<{ className?: string }>; + }> = [ + { value: "reports", label: "Reports", Icon: ShieldAlert }, + { value: "feedback", label: "Feedback", Icon: MessageSquare }, + ...(showStaffing + ? [{ value: "staffing" as const, label: "Staffing", Icon: Users }] + : []), + ]; + return ( +
    + {allTabs.map(({ value, label, Icon }) => ( + + ))} +
    + ); +} + +// ── Panel root ──────────────────────────────────────────────────────────── + +export function AdminConsolePanel({ + origin, + pubkey, + role, + source, +}: { + origin: string; + /** Active identity pubkey — all state is keyed on (pubkey, origin). */ + pubkey: string; + /** Principal role from probe — `"operator"` | `"moderator"` | undefined */ + role?: AdminPrincipalRole | null; + /** Source from probe — `"config"` | `"owner_fallback"` | `"db"` | undefined */ + source?: AdminPrincipalSource | null; +}) { + const isOperator = role === "operator"; + const [activeTab, setActiveTab] = useState("reports"); + // Increment whenever the (pubkey, origin) context changes to invalidate all + // in-flight useAsyncLoad effects via their effect-local `active` flags. + const generationRef = useRef(0); + const [generation, setGeneration] = useState(0); + + // biome-ignore lint/correctness/useExhaustiveDependencies: pubkey and origin are reactive props — effect fires when either changes to bump the generation fence + useEffect(() => { + generationRef.current += 1; + setGeneration(generationRef.current); + }, [pubkey, origin]); + + return ( +
    + {role && ( +
    + + {role} + {source && ( + {source.replace("_", " ")} + )} +
    + )} + + {activeTab === "reports" && ( + + )} + {activeTab === "feedback" && ( + + )} + {activeTab === "staffing" && isOperator && ( + + )} +
    + ); +} diff --git a/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx b/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx new file mode 100644 index 00000000000..7c6f0ff4625 --- /dev/null +++ b/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx @@ -0,0 +1,358 @@ +/** + * Shared helpers for the admin console panel sub-components. + * + * Exported from here to avoid duplication across AdminConsolePanel.tsx, + * AdminConsoleFeedbackTab.tsx, and AdminConsoleStaffingTab.tsx. + */ + +import { useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { AlertCircle, LoaderCircle } from "lucide-react"; +import { formatRelativeTime } from "../forum/lib/time"; + +// ── Generic async state ─────────────────────────────────────────────────── + +export type AsyncState = + | { status: "idle" } + | { status: "loading" } + | { status: "ok"; data: T } + | { status: "error"; message: string }; + +/** + * Async load hook with effect-local active-flag cancellation. + * + * Each effect invocation sets `active = true` and flips it to `false` in the + * cleanup function. Completions check `active` before calling setState, so a + * result that arrives after the deps changed (or the component unmounted) is + * silently discarded. + * + * `load` is stored in a ref so it is not a dependency of the effect — callers + * create it inline and `deps` + `generation` are the explicit trigger list. + */ +export function useAsyncLoad( + load: () => Promise, + deps: unknown[], + generation: number, +): AsyncState { + const [state, setState] = useState>({ status: "idle" }); + const loadRef = useRef(load); + loadRef.current = load; + + // biome-ignore lint/correctness/useExhaustiveDependencies: loadRef is a stable ref; deps and generation are the intentional trigger set + useEffect(() => { + let active = true; + setState({ status: "loading" }); + loadRef.current().then( + (data) => { + if (!active) return; + setState({ status: "ok", data }); + }, + (e: unknown) => { + if (!active) return; + setState({ + status: "error", + message: e instanceof Error ? e.message : String(e), + }); + }, + ); + return () => { + active = false; + }; + }, [...deps, generation]); + + return state; +} + +// ── Admin error message parsing ─────────────────────────────────────────── + +/** + * Extract a human-readable message from an admin mutation error. + * + * Native admin commands reject with `admin API error: {json}` where the JSON + * is the relay's error envelope (`{"error":{"code","message","requestId"}}`). + * This strips the prefix and returns the envelope's `message` field so the UI + * can surface "action kick requires the report to have an associated channel" + * instead of the raw JSON. Falls back to the raw string when the payload is + * not the expected shape (network errors, non-JSON bodies). + */ +export function adminErrorMessage(e: unknown): string { + const raw = e instanceof Error ? e.message : String(e); + const jsonStart = raw.indexOf("{"); + if (jsonStart === -1) return raw; + try { + const parsed = JSON.parse(raw.slice(jsonStart)); + const message = parsed?.error?.message; + return typeof message === "string" && message.length > 0 ? message : raw; + } catch { + return raw; + } +} + +/** + * Extract the relay's HTTP status from a rejected admin mutation, or `null`. + * + * Native mutation commands reject with a typed `AdminMutationError` + * (`{message, relayStatus, bodyComplete}`); the Tauri bridge surfaces it as + * `TauriInvokeError` whose `payload` is that object. `relayStatus` is a number + * only when the relay actually answered — `null`/absent for a transport or + * pre-send failure where no relay verdict exists. + */ +export function adminMutationRelayStatus(e: unknown): number | null { + if (e && typeof e === "object" && "payload" in e) { + const payload = (e as { payload: unknown }).payload; + if (payload && typeof payload === "object" && "relayStatus" in payload) { + const status = (payload as { relayStatus: unknown }).relayStatus; + if (typeof status === "number") return status; + } + } + return null; +} + +/** + * Whether the relay's full response body was read — an authoritative verdict. + * + * `AdminMutationError.bodyComplete` is `true` only when the relay answered AND + * its whole body was received. A status that arrives but whose body is lost + * mid-stream (or rejected over the size cap) is `false`: the outcome is + * unknown. Absent/non-boolean payloads (bare-string errors, non-typed + * rejections) read `false`, which is fail-safe — an unknown outcome preserves + * the idempotency key. + */ +export function adminMutationBodyComplete(e: unknown): boolean { + if (e && typeof e === "object" && "payload" in e) { + const payload = (e as { payload: unknown }).payload; + if (payload && typeof payload === "object" && "bodyComplete" in payload) { + const complete = (payload as { bodyComplete: unknown }).bodyComplete; + if (typeof complete === "boolean") return complete; + } + } + return false; +} + +/** + * Whether a failed mutation must reuse its idempotency `requestId` on retry. + * + * The id is preserved UNLESS the relay definitively rejected the request before + * committing — a non-409 4xx whose full body was read. Those (bad action, + * unauthorized, not found) refuse the input pre-commit, so a corrected + * resubmission is a genuinely new command and a fresh id is safe. + * + * Everything else preserves the id so the relay can dedupe against a commit + * that may have landed: + * - 409 — an idempotency claim or in-progress action already exists; + * - 5xx — the relay may have committed before failing; + * - a lost or truncated response body (status arrived, `bodyComplete` false — + * outcome unknown), including a truncated 4xx; + * - a transport or pre-send failure with no relay answer (`relayStatus` null). + * + * Status alone is insufficient: a truncated 4xx carries a definitive-looking + * status without an authoritative body, so the `bodyComplete` bit gates the + * reset. This replaces string-matching `"409"`/`"processing"` on the message, + * which missed the native layer's transport errors and cleared the id on + * exactly the ambiguous lost-response failures where reuse is required. + */ +export function preserveRequestIdOnError(e: unknown): boolean { + const status = adminMutationRelayStatus(e); + if (status === null) return true; + if (status === 409) return true; + if (status < 400 || status >= 500) return true; + // A non-409 4xx resets only when the relay's full body confirmed the verdict. + return !adminMutationBodyComplete(e); +} + +// ── Shared UI helpers ───────────────────────────────────────────────────── + +export function LoadingSpinner() { + return ( +
    + + Loading… +
    + ); +} + +export function ErrorMessage({ message }: { message: string }) { + return ( +
    + + {message} +
    + ); +} + +// ── Timestamp formatter ─────────────────────────────────────────────────── + +export function formatTimestamp(raw: string | null | undefined): string { + if (!raw) return "—"; + const date = new Date(raw); + if (Number.isNaN(date.getTime())) return raw; + const absolute = date.toLocaleString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + const rel = formatRelativeTime(Math.floor(date.getTime() / 1000)); + // Render relative label with the absolute value inline in parentheses. + return `${rel} (${absolute})`; +} + +// ── Structured detail row ───────────────────────────────────────────────── + +export function DetailRow({ + label, + value, + mono, +}: { + label: string; + value: string | null | undefined; + mono?: boolean; +}) { + return ( +
    + {label} + + {value ?? "—"} + +
    + ); +} + +// ── Attachment meta ─────────────────────────────────────────────────────── + +export type AttachmentMeta = { + /** Lowercase 64-hex SHA-256 as stored/returned by the relay. */ + sha256: string; + /** MIME type from the `m` imeta field. */ + mime: string; + /** Byte size from the `size` imeta field. */ + size: number; +}; + +/** + * Parse imeta attachment metadata from the relay's `tags: string[][]` wire + * format. Matches the reference SPA implementation in `admin-web/src/App.tsx`. + * + * Each `imeta` tag looks like: + * `["imeta", "url https://...", "m image/png", "x ", "size 12345"]` + * Each entry after `"imeta"` is a singleton `"key value"` string. + * + * Rejected: missing x/m/size, non-lowercase-hex x, non-positive size. + */ +export function parseImetaAttachments(tags: unknown): AttachmentMeta[] { + if (!Array.isArray(tags)) return []; + const result: AttachmentMeta[] = []; + for (const tag of tags) { + if (!Array.isArray(tag) || tag[0] !== "imeta") continue; + const values = new Map(); + for (const entry of (tag as string[]).slice(1)) { + const sep = typeof entry === "string" ? entry.indexOf(" ") : -1; + if (sep > 0) { + values.set(entry.slice(0, sep), entry.slice(sep + 1)); + } + } + const sha256 = values.get("x") ?? ""; + const mime = values.get("m") ?? ""; + const rawSize = values.get("size") ?? ""; + const size = Number(rawSize); + // Require exactly 64 lowercase hex chars for the hash (relay stores lowercase; + // uppercase returns 404). Require a non-empty MIME type and a positive size. + if ( + sha256.length !== 64 || + !/^[0-9a-f]{64}$/.test(sha256) || + !mime || + !Number.isFinite(size) || + size <= 0 + ) { + continue; + } + result.push({ sha256, mime, size }); + } + return result; +} + +// ── Community grouping ───────────────────────────────────────────────────── + +/** A run of rows that share one community, tagged with its display host. */ +export type CommunityGroup = { + /** Stable community identifier — used as the React key. */ + communityId: string; + /** Human-facing host label rendered as the group heading. */ + communityHost: string; + items: T[]; +}; + +/** React key + heading for rows whose source community has been purged. */ +const SEVERED_COMMUNITY_KEY = "__severed__"; +const SEVERED_COMMUNITY_HOST = "(source community removed)"; + +/** + * Group deployment-wide rows by community, preserving each community's + * first-seen order and the server's row order within it. + * + * The admin API returns reports and feedback across every community on the + * deployment; operators triage per community, so rows are bucketed by + * `communityId` (stable) and labelled by `communityHost` (display). A blank + * host falls back to the id so a group is never headed by an empty string. + * + * Feedback whose source community was purged carries a `null` `communityId` + * (tenant provenance severed, the row retained as operator evidence). Those + * rows bucket into a single "source community removed" group so a null key + * never collapses distinct rows or heads a group with an empty string. + */ +export function groupByCommunity< + T extends { communityId: string | null; communityHost: string | null }, +>(items: T[]): CommunityGroup[] { + const groups: CommunityGroup[] = []; + const byId = new Map>(); + for (const item of items) { + const key = item.communityId ?? SEVERED_COMMUNITY_KEY; + let group = byId.get(key); + if (!group) { + group = { + communityId: key, + communityHost: + item.communityId == null + ? SEVERED_COMMUNITY_HOST + : item.communityHost || item.communityId, + items: [], + }; + byId.set(key, group); + groups.push(group); + } + group.items.push(item); + } + return groups; +} + +/** + * Render community-grouped rows under per-community headings. + * + * A single community collapses to a flat list (no redundant heading); two or + * more render a labelled section each. `renderItem` produces the row for one + * entry — the caller owns row markup so navigation/testids are unchanged. + */ +export function CommunityGroupedList< + T extends { communityId: string | null; communityHost: string | null }, +>({ items, renderItem }: { items: T[]; renderItem: (item: T) => ReactNode }) { + const groups = groupByCommunity(items); + if (groups.length <= 1) { + return
      {items.map(renderItem)}
    ; + } + return ( +
    + {groups.map((group) => ( +
    +

    + {group.communityHost} +

    +
      {group.items.map(renderItem)}
    +
    + ))} +
    + ); +} diff --git a/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx b/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx new file mode 100644 index 00000000000..4c9dce7a86e --- /dev/null +++ b/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx @@ -0,0 +1,496 @@ +/** + * Settings card for the desktop admin console. + * + * Lets an operator enter the admin console URL (the value of `BUZZ_ADMIN_HOST` + * on their relay), then probes it to determine auth mode and whether the + * current app identity is on the allowlist. + * + * Identity boundary: the stateful body is rendered as + * `` so that React + * synchronously unmounts A's entire state tree before B is rendered. Logout + * (pubkeyHex → empty string) renders nothing, so A's probe state, saved + * origin, and panel are torn down at the render level — not in a passive effect. + * + * Renders the full admin panel when probe state is `nip98Authorized` or + * `disabled`. The `disabled` state means the relay does not require or + * validate a credential on the admin API — the desktop still signs outgoing + * requests, but the relay accepts them unconditionally. The panel works the + * same way in both states. + */ + +import { useEffect, useRef, useState } from "react"; +import { + AlertCircle, + Check, + CheckCircle2, + ChevronRight, + Copy, + Info, + LoaderCircle, +} from "lucide-react"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; +import { cn } from "@/shared/lib/cn"; +import { copyTextToClipboard } from "@/shared/lib/clipboard"; +import { + getAdminOrigin, + probeAdminOrigin, + setAdminOrigin, + discoverAdminOrigin, + type AdminPrincipalRole, + type AdminPrincipalSource, + type AdminProbeState, +} from "./api"; +import { AdminConsolePanel } from "./AdminConsolePanel"; +import { useIdentityQuery } from "@/shared/api/hooks"; + +// ── Probe state → UI copy ───────────────────────────────────────────────── + +// ── DeniedBadge — copy-icon button for the pubkey ───────────────────────── + +function DeniedBadge({ pubkeyHex }: { pubkeyHex: string }) { + const [copied, setCopied] = useState(false); + const resetTimer = useRef(undefined); + useEffect(() => () => window.clearTimeout(resetTimer.current), []); + + return ( + + + + Access denied + + + Your pubkey is not in{" "} + RELAY_OPERATOR_PUBKEYS. Ask your + relay operator to add: + + + + {pubkeyHex} + + + + + Other possible causes: clock skew > 60 s, relay config mismatch, or + the relay is running{" "} + BUZZ_ADMIN_AUTH=token instead of{" "} + nip98. + + + ); +} + +type ProbeUiState = + | { kind: "idle" } + | { kind: "probing" } + | { + kind: "authorized"; + origin: string; + role?: AdminPrincipalRole | null; + source?: AdminPrincipalSource | null; + } + | { kind: "denied"; pubkeyHex: string } + | { kind: "tokenMode" } + | { kind: "disabled"; origin: string } + | { kind: "notAdminApi" } + | { kind: "networkOrIntercepted" } + | { kind: "error"; message: string }; + +function ProbeStatusBadge({ uiState }: { uiState: ProbeUiState }) { + if (uiState.kind === "idle") return null; + if (uiState.kind === "probing") { + return ( + + + Probing… + + ); + } + if (uiState.kind === "authorized") { + return ( + + + Connected + + ); + } + if (uiState.kind === "denied") { + return ; + } + if (uiState.kind === "tokenMode") { + return ( + + + Bearer-token mode. Use the web console — the desktop app only supports + NIP-98 auth. + + ); + } + if (uiState.kind === "disabled") { + return ( + + + Auth is disabled on this relay. The admin console is accessible without + a credential. + + ); + } + if (uiState.kind === "notAdminApi") { + return ( + + + No admin API found at this origin. Check the URL matches{" "} + BUZZ_ADMIN_HOST. + + ); + } + if (uiState.kind === "networkOrIntercepted") { + return ( + + + Could not reach the relay. Check: network, TLS certificate, DNS, or + whether a VPN/SSO layer (e.g. Cloudflare Access) intercepts this host. + + ); + } + // error + return ( + + + {uiState.message} + + ); +} + +function probeStateToUiState( + result: { + state: AdminProbeState; + role?: AdminPrincipalRole | null; + source?: AdminPrincipalSource | null; + }, + origin: string, + pubkeyHex: string, +): ProbeUiState { + switch (result.state) { + case "nip98Authorized": + return { + kind: "authorized", + origin, + role: result.role, + source: result.source, + }; + case "nip98Denied": + return { kind: "denied", pubkeyHex }; + case "tokenMode": + return { kind: "tokenMode" }; + case "disabled": + return { kind: "disabled", origin }; + case "notAdminApi": + return { kind: "notAdminApi" }; + case "networkOrIntercepted": + return { kind: "networkOrIntercepted" }; + } +} + +// ── Main card ───────────────────────────────────────────────────────────── + +export function AdminConsoleSettingsCard() { + const { data: identity } = useIdentityQuery(); + const pubkeyHex = identity?.pubkey ?? ""; + + return ( +
    + + {pubkeyHex ? ( + + ) : null} +
    + ); +} + +// ── Stateful session — keyed by pubkeyHex ───────────────────────────────── +// +// React's `key` prop causes the parent to unmount this component entirely when +// the pubkey changes. That means: +// - A→B switch: A's entire state tree (originInput, savedOrigin, probeUiState, +// isSaving, in-flight probes) is destroyed synchronously before B mounts. +// - Logout (pubkeyHex → ""): the parent renders `null`, so A's state is gone +// before any new render begins. +// +// This eliminates the passive-effect reset race where the parent rendered with +// B's pubkey and A's stale origin/authorized state for one render cycle. + +function AdminConsoleSettingsSession({ pubkeyHex }: { pubkeyHex: string }) { + const [originInput, setOriginInput] = useState(""); + const [savedOrigin, setSavedOrigin] = useState(null); + const [probeUiState, setProbeUiState] = useState({ + kind: "idle", + }); + const [isSaving, setIsSaving] = useState(false); + + // In-flight probe abort controller. Does not cancel the Tauri native request + // (not cancellable), but prevents a stale probe result from updating UI state. + const probeAbortRef = useRef(null); + + // Save/probe context token: captures (pubkey, origin) at the time a save + // starts. handleSave checks this before committing any state so a delayed + // save cannot repopulate the wrong session. + // + // On unmount, the cleanup effect below sets sessionTokenRef.current = null. + // Every handleSave continuation leg checks `sessionTokenRef.current !== token` + // (null !== token object) → returns early on all paths. This is StrictMode-safe: + // StrictMode's simulated cleanup fires the null assignment, then the re-mount + // re-arms the ref when the next handleSave sets `sessionTokenRef.current = token`. + type SessionToken = { pubkey: string; origin: string }; + const sessionTokenRef = useRef(null); + + // Synchronously abort any active probe and reset probe UI state. + // Call before starting a new probe or on any input change. + function abortAndResetProbe() { + probeAbortRef.current?.abort(); + probeAbortRef.current = null; + setProbeUiState({ kind: "idle" }); + } + + // Null sessionTokenRef on unmount so A's deferred handleSave continuation + // fails the token check on all legs after A's component is torn down. Paired + // with the load-saved-origin effect below: that effect has an explicit + // lint suppression; this cleanup-only effect has no deps and Biome accepts it. + useEffect(() => { + return () => { + sessionTokenRef.current = null; + }; + }, []); + + // Load saved origin on mount (runs once per session because the component + // is keyed by pubkeyHex — re-mount = new pubkey). When nothing is saved, + // attempt NIP-11 auto-discovery of the admin origin from the connected + // relay so the operator never has to type a URL on the happy path. + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional mount-once effect; identity boundary is the key prop on this component — it unmounts/remounts on pubkey change, so [] is correct. + useEffect(() => { + let active = true; + void (async () => { + try { + const saved = await getAdminOrigin(pubkeyHex); + if (!active) return; + if (saved) { + // A persisted origin (manual fallback) takes precedence over + // discovery — the operator explicitly chose it. + setSavedOrigin(saved); + setOriginInput(saved); + runProbe(saved); + return; + } + // No saved origin: auto-discover from the relay's NIP-11 `admin_api`. + // Best-effort — a relay error, an absent field, or an advertised value + // that fails validation falls back to manual entry, never an error. + let discovered: string | null = null; + try { + discovered = await discoverAdminOrigin(); + } catch { + discovered = null; + } + if (!active) return; + if (discovered) { + setSavedOrigin(discovered); + setOriginInput(discovered); + runProbe(discovered); + } else { + setSavedOrigin(null); + setOriginInput(""); + } + } catch (e) { + if (!active) return; + // Surface storage/signing errors rather than silently degrading. + setProbeUiState({ + kind: "error", + message: e instanceof Error ? e.message : String(e), + }); + setSavedOrigin(null); + setOriginInput(""); + } + })(); + return () => { + active = false; + }; + }, []); // Empty: runs once per session mount; identity boundary is the key prop. + + function runProbe(origin: string) { + probeAbortRef.current?.abort(); + const controller = new AbortController(); + probeAbortRef.current = controller; + + setProbeUiState({ kind: "probing" }); + + void (async () => { + try { + const result = await probeAdminOrigin(origin); + if (controller.signal.aborted) return; + setProbeUiState(probeStateToUiState(result, origin, pubkeyHex)); + } catch (e) { + if (controller.signal.aborted) return; + setProbeUiState({ + kind: "error", + message: e instanceof Error ? e.message : String(e), + }); + } + })(); + } + + async function handleSave() { + const trimmed = originInput.trim(); + // Capture (pubkey, origin) token at save-start time. The check below + // ensures a delayed completion cannot write into a different session. + const token: SessionToken = { pubkey: pubkeyHex, origin: trimmed }; + sessionTokenRef.current = token; + + setIsSaving(true); + abortAndResetProbe(); + try { + if (!trimmed) { + const canonical = await setAdminOrigin(null, pubkeyHex); + // Discard if the session changed while the native call was in flight. + if (sessionTokenRef.current !== token) return; + setSavedOrigin(canonical); + setProbeUiState({ kind: "idle" }); + return; + } + const canonical = await setAdminOrigin(trimmed, pubkeyHex); + if (sessionTokenRef.current !== token) return; + setSavedOrigin(canonical); + if (canonical) { + runProbe(canonical); + } else { + setProbeUiState({ kind: "idle" }); + } + } catch (e) { + if (sessionTokenRef.current !== token) return; + setProbeUiState({ + kind: "error", + message: e instanceof Error ? e.message : String(e), + }); + } finally { + if (sessionTokenRef.current === token) setIsSaving(false); + } + } + + const inputChanged = originInput.trim() !== (savedOrigin ?? ""); + const isPanelVisible = + (probeUiState.kind === "authorized" || probeUiState.kind === "disabled") && + savedOrigin !== null; + + return ( + <> +
    +
    + + + Advanced: admin origin + +
    +
    + { + setOriginInput(e.target.value); + // General reset: abort and clear probe state on every input + // change, not only when state is `probing`. This prevents a + // stale probe result from a previous value being committed. + abortAndResetProbe(); + }} + placeholder="https://admin.yourrelay.example.com" + spellCheck={false} + type="url" + value={originInput} + onKeyDown={(e) => { + if (e.key === "Enter") void handleSave(); + }} + /> + + {savedOrigin && ( + + )} +
    +
    +
    + +
    + +
    +
    + + {isPanelVisible && savedOrigin && ( + + )} + + ); +} diff --git a/desktop/src/features/admin-console/AdminConsoleStaffingTab.tsx b/desktop/src/features/admin-console/AdminConsoleStaffingTab.tsx new file mode 100644 index 00000000000..37127e0deff --- /dev/null +++ b/desktop/src/features/admin-console/AdminConsoleStaffingTab.tsx @@ -0,0 +1,221 @@ +/** + * Staffing tab — Operator-only UI for managing relay_operators rows. + * + * Source badges distinguish config-backed entries (immutable via API) from + * DB-managed entries (can be added/removed). 409 conflicts from the server + * (config-backed key modification attempts) are surfaced with a clear message. + */ + +import { useState } from "react"; +import { LoaderCircle, Trash2 } from "lucide-react"; +import { Button } from "@/shared/ui/button"; +import { Badge } from "@/shared/ui/badge"; +import { + deleteAdminOperator, + listAdminOperators, + putAdminOperator, + type AdminOperatorDto, +} from "./api"; +import { + type AsyncState, + ErrorMessage, + LoadingSpinner, + useAsyncLoad, +} from "./AdminConsolePanelHelpers"; + +// ── Source badge ────────────────────────────────────────────────────────── + +/** Source badge for an operator entry. */ +function SourceBadge({ + source, +}: { + source: "config" | "owner_fallback" | "db"; +}) { + const label: Record = { + config: "config", + owner_fallback: "owner (fallback)", + db: "db", + }; + const variant: Record = { + config: "secondary", + owner_fallback: "secondary", + db: "outline", + }; + return ( + + {label[source] ?? source} + + ); +} + +// ── Staffing tab ────────────────────────────────────────────────────────── + +export function StaffingTab({ + origin, + pubkey, + generation, +}: { + origin: string; + pubkey: string; + generation: number; +}) { + const [listGen, setListGen] = useState(0); + const [addPubkey, setAddPubkey] = useState(""); + const [addRole, setAddRole] = useState<"operator" | "moderator">("moderator"); + const [isAdding, setIsAdding] = useState(false); + const [addError, setAddError] = useState(null); + const [actionError, setActionError] = useState(null); + const [workingPubkey, setWorkingPubkey] = useState(null); + + const listState: AsyncState = useAsyncLoad( + () => listAdminOperators(origin), + [origin, pubkey], + generation + listGen, + ); + + const handleAdd = async () => { + const trimmed = addPubkey.trim().toLowerCase(); + if (!trimmed) return; + setAddError(null); + setIsAdding(true); + try { + await putAdminOperator(origin, trimmed, addRole); + setAddPubkey(""); + setListGen((g) => g + 1); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + // 409 = config-backed key; surface clearly + setAddError( + msg.includes("409") + ? "This pubkey is config-backed and cannot be changed via the API." + : msg, + ); + } finally { + setIsAdding(false); + } + }; + + const handleRemove = async (opPubkey: string) => { + setActionError(null); + setWorkingPubkey(opPubkey); + try { + await deleteAdminOperator(origin, opPubkey); + setListGen((g) => g + 1); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setActionError( + msg.includes("409") + ? `Cannot remove ${opPubkey.slice(0, 16)}…: config-backed key.` + : msg, + ); + } finally { + setWorkingPubkey(null); + } + }; + + return ( +
    + {/* Add operator form */} +
    +

    + Add operator +

    +
    + setAddPubkey(e.target.value)} + placeholder="64-hex pubkey" + type="text" + value={addPubkey} + /> + + +
    + {addError &&

    {addError}

    } +
    + + {/* Operator list */} + {listState.status === "loading" && } + {listState.status === "error" && ( + + )} + {actionError && } + {listState.status === "ok" && ( +
      + {listState.data.length === 0 && ( +

      + No operators configured. +

      + )} + {listState.data.map((op: AdminOperatorDto) => { + const isConfigBacked = op.sources.some( + (s) => s === "config" || s === "owner_fallback", + ); + return ( +
    • +
      +

      {op.pubkey}

      +
      + {op.effectiveRole} + {op.sources.map((s) => ( + + ))} +
      +
      + +
    • + ); + })} +
    + )} +
    + ); +} diff --git a/desktop/src/features/admin-console/adminConsolePanel.test.mjs b/desktop/src/features/admin-console/adminConsolePanel.test.mjs new file mode 100644 index 00000000000..b96c0e20418 --- /dev/null +++ b/desktop/src/features/admin-console/adminConsolePanel.test.mjs @@ -0,0 +1,1175 @@ +/** + * Behavior and race tests for AdminConsoleSettingsCard / AdminConsoleSettingsSession. + * + * Tests mount the REAL production components (including the key-prop session + * boundary, sessionTokenRef fence, and abortAndResetProbe wiring) against a + * mocked Tauri IPC bridge and a real QueryClientProvider. + * + * This file uses the hand-rolled MinimalDocument shim (same pattern as + * useLoadArchivedObserverEvents.test.mjs) and covers prop-driven and query- + * driven tests that do NOT require native event dispatch through React 19's + * container-level delegation: + * + * What makes these tests authoritative — they fail if: + * - `pubkeyHex ? : null` render gate removed (authorized-logout-teardown) + * - `key={pubkeyHex}` boundary is removed (identity-switch test) + * - `active` flag cleanup is removed from useAsyncLoad (old-list-after-new-list) + * - the `getAdminOrigin()` catch is changed to silent-degrade (storage-error test) + * + * authorized-logout-teardown lives here (MinimalDocument, not jsdom) because the test is + * query-driven (act + qc.setQueryData + settle), not event-driven. The MinimalDocument + * suite handles async transitions cleanly without the jsdom global scheduler. + * + * Cross-identity delayed-save and all event-driven tests (origin-edit, detail-navigation, + * attachment-unmount, same-session-save-race) live in adminConsolePanelEvents.jsdom-test.mjs + * where fireEvent dispatches native events through React 19's container-level delegation. + * + * Also covers: + * - parseImetaAttachments wire contract (imported from AdminConsolePanel) + */ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +// ── Minimal DOM shim ────────────────────────────────────────────────────────── +// +// Installs the minimum DOM surface that React + react-dom/client need. +// Uses the same pattern as useLoadArchivedObserverEvents.test.mjs to avoid +// jsdom background timers that prevent the process from exiting cleanly. + +function installDOMShim() { + class MinimalEventTarget { + constructor() { + this._listeners = {}; + } + addEventListener(type, fn) { + if (!this._listeners[type]) this._listeners[type] = []; + this._listeners[type].push(fn); + } + removeEventListener(type, fn) { + if (this._listeners[type]) { + this._listeners[type] = this._listeners[type].filter((f) => f !== fn); + } + } + dispatchEvent(e) { + for (const fn of this._listeners[e.type] ?? []) fn(e); + return true; + } + } + + class MinimalNode extends MinimalEventTarget { + constructor(tagName) { + super(); + this.tagName = tagName?.toUpperCase?.() ?? tagName; + this.nodeName = this.tagName; + this.children = []; + this.childNodes = []; + this.style = {}; + this.nodeType = 1; + this.parentNode = null; + this.attributes = []; + this._data = {}; + } + get ownerDocument() { + return globalThis.document; + } + get firstChild() { + return this.childNodes[0] ?? null; + } + get lastChild() { + return this.childNodes[this.childNodes.length - 1] ?? null; + } + get nextSibling() { + return null; + } + get previousSibling() { + return null; + } + get nodeValue() { + return null; + } + set nodeValue(_v) {} + get textContent() { + return this.childNodes.map((c) => c.textContent ?? "").join(""); + } + set textContent(v) { + this.childNodes = []; + if (v) { + const t = globalThis.document.createTextNode(v); + this.appendChild(t); + } + } + appendChild(child) { + child.parentNode = this; + this.childNodes.push(child); + if (child.nodeType === 1) this.children.push(child); + return child; + } + removeChild(child) { + this.childNodes = this.childNodes.filter((c) => c !== child); + this.children = this.children.filter((c) => c !== child); + return child; + } + insertBefore(newNode, refNode) { + if (!refNode) return this.appendChild(newNode); + const i = this.childNodes.indexOf(refNode); + if (i < 0) return this.appendChild(newNode); + newNode.parentNode = this; + this.childNodes.splice(i, 0, newNode); + if (newNode.nodeType === 1) this.children.push(newNode); + return newNode; + } + replaceChild(newNode, oldNode) { + const i = this.childNodes.indexOf(oldNode); + if (i >= 0) { + newNode.parentNode = this; + this.childNodes[i] = newNode; + const j = this.children.indexOf(oldNode); + if (j >= 0) this.children[j] = newNode; + } + return oldNode; + } + contains(node) { + if (!node) return false; + return this === node || this.childNodes.some((c) => c?.contains?.(node)); + } + setAttribute(name, value) { + this._data[name] = value; + } + getAttribute(name) { + return this._data[name] ?? null; + } + hasAttribute(name) { + return Object.hasOwn(this._data, name); + } + removeAttribute(name) { + delete this._data[name]; + } + querySelector(selector) { + // Support [data-testid='...'] and simple tag selectors. + const attrMatch = selector.match(/\[([^\]=']+)(?:='([^']*)')?\]/); + const tagMatch = selector.match(/^([a-zA-Z]+)$/); + for (const node of this._allElements()) { + if (attrMatch) { + const [, attrName, attrVal] = attrMatch; + const nodeVal = node.getAttribute?.(attrName); + if (attrVal === undefined ? nodeVal !== null : nodeVal === attrVal) { + return node; + } + } else if (tagMatch) { + if (node.tagName?.toLowerCase() === tagMatch[1].toLowerCase()) { + return node; + } + } + } + return null; + } + querySelectorAll(selector) { + const attrMatch = selector.match(/\[([^\]=']+)(?:='([^']*)')?\]/); + const results = []; + for (const node of this._allElements()) { + if (attrMatch) { + const [, attrName, attrVal] = attrMatch; + const nodeVal = node.getAttribute?.(attrName); + if (attrVal === undefined ? nodeVal !== null : nodeVal === attrVal) { + results.push(node); + } + } + } + return results; + } + *_allElements() { + for (const child of this.childNodes) { + yield child; + if (child._allElements) yield* child._allElements(); + } + } + get innerHTML() { + return this.childNodes + .map((c) => c.outerHTML ?? c.textContent ?? "") + .join(""); + } + set innerHTML(_v) {} + get outerHTML() { + return `<${this.tagName?.toLowerCase() ?? "div"}>...`; + } + focus() {} + blur() {} + getBoundingClientRect() { + return { top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0 }; + } + cloneNode() { + return new MinimalNode(this.tagName); + } + get value() { + return this._value ?? ""; + } + set value(v) { + this._value = v; + } + get disabled() { + return this._disabled ?? false; + } + set disabled(v) { + this._disabled = v; + } + get type() { + return this._type ?? ""; + } + set type(v) { + this._type = v; + } + get checked() { + return this._checked ?? false; + } + set checked(v) { + this._checked = v; + } + get className() { + return this._className ?? ""; + } + set className(v) { + this._className = v; + } + get id() { + return this._id ?? ""; + } + set id(v) { + this._id = v; + } + get placeholder() { + return this._placeholder ?? ""; + } + set placeholder(v) { + this._placeholder = v; + } + get readOnly() { + return this._readOnly ?? false; + } + set readOnly(v) { + this._readOnly = v; + } + get tabIndex() { + return this._tabIndex ?? -1; + } + set tabIndex(v) { + this._tabIndex = v; + } + get href() { + return this._href ?? ""; + } + set href(v) { + this._href = v; + } + get src() { + return this._src ?? ""; + } + set src(v) { + this._src = v; + } + get alt() { + return this._alt ?? ""; + } + set alt(v) { + this._alt = v; + } + } + + class MinimalTextNode extends MinimalEventTarget { + constructor(value) { + super(); + this.nodeType = 3; + this.nodeName = "#text"; + this.nodeValue = value; + this.parentNode = null; + } + get textContent() { + return this.nodeValue; + } + set textContent(v) { + this.nodeValue = v; + } + contains(node) { + return this === node; + } + } + + class MinimalDocument extends MinimalEventTarget { + constructor() { + super(); + this.nodeType = 9; + this.nodeName = "#document"; + this._body = null; + this._head = null; + } + createElement(tagName) { + return new MinimalNode(tagName); + } + createTextNode(value) { + return new MinimalTextNode(value); + } + createComment(value) { + const n = new MinimalNode("#comment"); + n.nodeType = 8; + n.nodeValue = value; + return n; + } + createElementNS(_ns, tagName) { + return this.createElement(tagName); + } + get body() { + if (!this._body) { + this._body = this.createElement("body"); + } + return this._body; + } + get head() { + if (!this._head) { + this._head = this.createElement("head"); + } + return this._head; + } + get activeElement() { + return null; + } + contains(node) { + return node != null; + } + querySelector(sel) { + return this.body.querySelector(sel); + } + querySelectorAll(sel) { + return this.body.querySelectorAll(sel); + } + get documentElement() { + return this.body; + } + } + + const doc = new MinimalDocument(); + globalThis.document = doc; + globalThis.HTMLElement = MinimalNode; + globalThis.HTMLInputElement = MinimalNode; + globalThis.HTMLButtonElement = MinimalNode; + globalThis.HTMLDivElement = MinimalNode; + globalThis.HTMLSpanElement = MinimalNode; + globalThis.HTMLAnchorElement = MinimalNode; + globalThis.HTMLFormElement = MinimalNode; + globalThis.HTMLIFrameElement = MinimalNode; + globalThis.SVGElement = MinimalNode; + globalThis.SVGSVGElement = MinimalNode; + globalThis.Text = MinimalTextNode; + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + process.env.IS_REACT_ACT_ENVIRONMENT = "true"; + + globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); + globalThis.cancelAnimationFrame = (id) => clearTimeout(id); + + globalThis.MutationObserver = class { + observe() {} + disconnect() {} + takeRecords() { + return []; + } + }; + + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + + globalThis.IntersectionObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + + globalThis.getComputedStyle = () => ({ + getPropertyValue: () => "", + setProperty: () => {}, + }); + + if (typeof globalThis.window === "undefined") { + Object.defineProperty(globalThis, "window", { + value: globalThis, + configurable: true, + }); + } + if (!Object.getOwnPropertyDescriptor(globalThis, "navigator")?.value) { + Object.defineProperty(globalThis, "navigator", { + value: { userAgent: "node" }, + configurable: true, + }); + } +} + +installDOMShim(); + +// ── Tauri IPC interceptor ───────────────────────────────────────────────────── + +/** @type {Map Promise>} */ +const ipcHandlers = new Map(); + +function setIpcHandler(cmd, fn) { + ipcHandlers.set(cmd, fn); +} +function clearIpcHandlers() { + ipcHandlers.clear(); +} + +globalThis.__TAURI_INTERNALS__ = { + invoke(cmd, args) { + const handler = ipcHandlers.get(cmd); + if (handler) return handler(args); + return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); + }, + transformCallback(_cb) { + return Math.random(); + }, +}; + +// ── Production imports ──────────────────────────────────────────────────────── + +import React from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { AdminConsoleSettingsCard } from "./AdminConsoleSettingsCard.tsx"; +import { + AdminConsolePanel, + parseImetaAttachments, +} from "./AdminConsolePanel.tsx"; +import { resolveAdminReport } from "./api.ts"; + +// ── Deferred promise helper ─────────────────────────────────────────────────── + +function deferred() { + let resolve, reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +// ── Mount helpers ───────────────────────────────────────────────────────────── + +function makeQueryClient(pubkeyHex) { + const qc = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0, staleTime: Infinity }, + }, + }); + // Always set identity to an object (even for empty pubkey) so React Query + // never calls queryFn = getIdentity (which would hit the unmocked IPC). + // Component reads pubkeyHex = identity?.pubkey ?? "" — so { pubkey: "" } + // gives pubkeyHex = "" (logged-out state). + qc.setQueryData(["identity"], { pubkey: pubkeyHex }); + return qc; +} + +function mountCard(qc) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const doRender = async () => { + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: qc }, + React.createElement(AdminConsoleSettingsCard), + ), + ); + }); + }; + const unmount = async () => { + await act(async () => { + root.unmount(); + }); + document.body.removeChild(container); + }; + return { container, doRender, unmount }; +} + +/** + * Mount AdminConsolePanel directly (not through the settings card). + * Used for panel-level race tests (list, detail, attachment). + */ +function mountPanel({ origin, pubkey }) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const doRender = async ({ origin: o, pubkey: p } = { origin, pubkey }) => { + await act(async () => { + root.render( + React.createElement(AdminConsolePanel, { origin: o, pubkey: p }), + ); + }); + }; + const unmount = async () => { + await act(async () => { + root.unmount(); + }); + document.body.removeChild(container); + }; + return { container, doRender, unmount }; +} + +// Flush React effects and timers. +async function settle(ms = 20) { + await act(async () => { + await new Promise((r) => setTimeout(r, ms)); + }); +} + +afterEach(() => { + clearIpcHandlers(); +}); + +// ── parseImetaAttachments ───────────────────────────────────────────────────── + +test("parseImetaAttachments: parses a well-formed imeta tag", () => { + const sha256 = "a".repeat(64); + const tags = [ + [ + "imeta", + `url https://example.com/a.jpg`, + `m image/jpeg`, + `x ${sha256}`, + "size 1234", + ], + ]; + const result = parseImetaAttachments(tags); + assert.equal(result.length, 1); + assert.equal(result[0].sha256, sha256); + assert.equal(result[0].mime, "image/jpeg"); + assert.equal(result[0].size, 1234); +}); + +test("parseImetaAttachments: skips tags that are not imeta", () => { + const tags = [ + ["p", "abc123"], + ["e", "def456"], + ]; + assert.deepEqual(parseImetaAttachments(tags), []); +}); + +test("parseImetaAttachments: rejects uppercase x hash", () => { + const sha256Upper = "A".repeat(64); + const tags = [["imeta", `x ${sha256Upper}`, "m image/png", "size 100"]]; + assert.deepEqual(parseImetaAttachments(tags), []); +}); + +test("parseImetaAttachments: rejects hash shorter than 64 chars", () => { + const tags = [["imeta", `x ${"a".repeat(63)}`, "m image/png", "size 100"]]; + assert.deepEqual(parseImetaAttachments(tags), []); +}); + +test("parseImetaAttachments: rejects hash longer than 64 chars", () => { + const tags = [["imeta", `x ${"a".repeat(65)}`, "m image/png", "size 100"]]; + assert.deepEqual(parseImetaAttachments(tags), []); +}); + +test("parseImetaAttachments: rejects missing m field", () => { + const sha256 = "b".repeat(64); + const tags = [["imeta", `x ${sha256}`, "size 100"]]; + assert.deepEqual(parseImetaAttachments(tags), []); +}); + +test("parseImetaAttachments: rejects missing size field", () => { + const sha256 = "c".repeat(64); + const tags = [["imeta", `x ${sha256}`, "m image/png"]]; + assert.deepEqual(parseImetaAttachments(tags), []); +}); + +test("parseImetaAttachments: rejects non-positive size", () => { + const sha256 = "d".repeat(64); + const tags = [["imeta", `x ${sha256}`, "m image/png", "size 0"]]; + assert.deepEqual(parseImetaAttachments(tags), []); + const tagsNeg = [["imeta", `x ${sha256}`, "m image/png", "size -1"]]; + assert.deepEqual(parseImetaAttachments(tagsNeg), []); +}); + +test("parseImetaAttachments: parses multiple imeta tags", () => { + const sha1 = "e".repeat(64); + const sha2 = "f".repeat(64); + const tags = [ + ["imeta", `x ${sha1}`, "m image/png", "size 111"], + ["imeta", `x ${sha2}`, "m image/jpeg", "size 222"], + ]; + const result = parseImetaAttachments(tags); + assert.equal(result.length, 2); + assert.equal(result[0].sha256, sha1); + assert.equal(result[1].sha256, sha2); +}); + +test("parseImetaAttachments: returns empty array for non-array input", () => { + assert.deepEqual(parseImetaAttachments(null), []); + assert.deepEqual(parseImetaAttachments({}), []); + assert.deepEqual(parseImetaAttachments("imeta"), []); +}); + +test("parseImetaAttachments: extracts from camelCase AdminFeedback relay fixture", () => { + // Exact wire shape emitted by the relay (serde rename_all = "camelCase"). + const sha256 = + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + const fixture = { + id: "00000000-0000-0000-0000-000000000001", + reportType: "feedback", + bodySummary: "App crashes on startup", + body: "Full description here", + receivedAt: 1700000000, + tags: [ + [ + "imeta", + `url https://relay.example.com/files/${sha256}`, + `m image/png`, + `x ${sha256}`, + "size 98765", + ], + ], + }; + const result = parseImetaAttachments(fixture.tags); + assert.equal(result.length, 1); + assert.equal(result[0].sha256, sha256); + assert.equal(result[0].mime, "image/png"); + assert.equal(result[0].size, 98765); +}); + +// ── Component-level session boundary and race tests ─────────────────────────── +// +// Each test below mounts the production AdminConsoleSettingsCard (including +// AdminConsoleSettingsSession keyed by pubkeyHex) and drives Tauri IPC calls +// via deferred promises. These tests fail if the identity boundary or fences +// are removed from the production code. + +test("authorized-logout-teardown: A's session is gone when pubkeyHex becomes empty", async () => { + // Verifies the `pubkeyHex ? : null` render + // gate in AdminConsoleSettingsCard. Drives the full authorized→logout transition: + // mount with a real identity A, drive to authorized (input visible, panel rendered), + // then switch pubkeyHex to "" and assert both input and panel are gone. + // + // Fails if the render gate is removed: after the transition to pubkeyHex="", + // AdminConsoleSettingsSession re-mounts with empty pubkey and the input remains. + // + // Design: identical to identity-switch — act + qc.setQueryData + settle. + // React Query's notifyManager fires onStoreChange via setTimeout(0), which + // act() drains during the inner settle(). The MinimalDocument environment + // handles this cleanly without the jsdom global scheduler side-effects. + + const pubkeyA = "a".repeat(64); + const originA = "https://admin-a.example.com"; + + setIpcHandler("get_admin_origin", (args) => { + if (args?.expectedPubkey === pubkeyA) return Promise.resolve(originA); + return Promise.resolve(null); + }); + setIpcHandler("admin_probe", () => + Promise.resolve({ state: "nip98Authorized" }), + ); + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const qc = makeQueryClient(pubkeyA); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(50); + + // A is authorized — input and panel must be present. + const inputA = container.querySelector("[data-testid='admin-origin-input']"); + assert.ok(inputA, "input must render for pubkeyA in authorized state"); + const panelA = container.querySelector("[data-testid='admin-console-panel']"); + assert.ok(panelA, "admin-console-panel must render when A is authorized"); + + // Transition to logout — same pattern as identity-switch. + await act(async () => { + qc.setQueryData(["identity"], { pubkey: "" }); + await new Promise((r) => setTimeout(r, 25)); + }); + + // After the transition: gate renders null, both input and panel must be gone. + const inputAfter = container.querySelector( + "[data-testid='admin-origin-input']", + ); + const panelAfter = container.querySelector( + "[data-testid='admin-console-panel']", + ); + + await unmount(); + + assert.equal( + inputAfter, + null, + "admin origin input must not render when pubkeyHex is empty — render gate missing", + ); + assert.equal( + panelAfter, + null, + "admin-console-panel must not render after logout — render gate missing", + ); +}); +test("identity-switch: fresh session mounts with empty input on pubkey change", async () => { + // Verifies the key-prop boundary. Without `key={pubkeyHex}`, React reuses + // the component and A's origin state survives the switch to B. + + const pubkeyA = "a".repeat(64); + const pubkeyB = "b".repeat(64); + const originA = "https://admin-a.example.com"; + + setIpcHandler("get_admin_origin", (args) => { + if (args?.expectedPubkey === pubkeyA) return Promise.resolve(originA); + return Promise.resolve(null); + }); + setIpcHandler("admin_probe", () => Promise.resolve({ state: "disabled" })); + + const qc = makeQueryClient(pubkeyA); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(25); + + const inputA = container.querySelector("[data-testid='admin-origin-input']"); + assert.ok(inputA, "input must render for pubkeyA"); + assert.equal( + inputA.value, + originA, + "input must show A's saved origin after mount", + ); + + // Switch to pubkeyB — key prop causes a full remount of AdminConsoleSettingsSession. + // B has no saved origin, so the input must be empty. + setIpcHandler("get_admin_origin", (args) => { + if (args?.expectedPubkey === pubkeyB) return Promise.resolve(null); + // Reject any call with A's pubkey — must not fire after the switch. + return Promise.reject(new Error("unexpected pubkey after identity switch")); + }); + + await act(async () => { + qc.setQueryData(["identity"], { pubkey: pubkeyB }); + await new Promise((r) => setTimeout(r, 25)); + }); + + const inputB = container.querySelector("[data-testid='admin-origin-input']"); + assert.ok(inputB, "input must render for pubkeyB"); + assert.equal( + inputB.value, + "", + "input must be empty for pubkeyB — key boundary ensures fresh state, not stale A origin", + ); + await unmount(); +}); + +test("storage-error surfaced: getAdminOrigin rejection shows error in UI", async () => { + // Verifies the mount-effect catch sets `{ kind: 'error', message }`. + // Removing error propagation from the catch (silent degrade) causes the + // error text to not appear. + + const pubkey = "c".repeat(64); + const errorMsg = "stored admin console origin is invalid (removed): bad json"; + setIpcHandler("get_admin_origin", () => Promise.reject(new Error(errorMsg))); + + const qc = makeQueryClient(pubkey); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(25); + + // The error or its key fragment must be visible in the rendered tree. + const bodyText = container.textContent ?? ""; + const hasError = + bodyText.includes("invalid") || + bodyText.includes("bad json") || + bodyText.includes("removed") || + bodyText.includes("admin console origin"); + assert.ok( + hasError, + `error from getAdminOrigin must appear in UI; body text: "${bodyText.slice(0, 300)}"`, + ); + await unmount(); +}); + +// origin-edit (abortAndResetProbe wired to onChange) is covered by +// adminConsolePanelEvents.jsdom-test.mjs where fireEvent dispatches native +// events through React 19's container-level delegation. + +// ── AdminConsolePanel race tests ────────────────────────────────────────────── +// +// These tests mount AdminConsolePanel directly (bypassing the settings card) +// and use deferred promises to simulate in-flight native requests. They verify +// the effect-local `active` flag cancellation in useAsyncLoad, the generation +// fence in AdminConsolePanel, and the loadGenRef cleanup in AttachmentViewer. + +test("old-list-after-new-list: stale list result does not replace new list after pubkey change", async () => { + // Verifies the effect-local `active` flag in useAsyncLoad. + // + // Scenario: panel renders with pubkeyA/originA → list query starts (deferred). + // Before it resolves, panel re-renders with pubkeyB/originB → a new list + // query starts. Then the old (A's) deferred resolves: the active flag in + // A's effect closure is already false (effect re-ran with B's deps), so + // A's result is discarded. Only B's result may commit. + // + // This test fails if useAsyncLoad's active-flag cleanup is removed, because + // A's result would overwrite B's list state. + + const originA = "https://admin-a.example.com"; + const originB = "https://admin-b.example.com"; + const pubkeyA = "a".repeat(64); + const pubkeyB = "b".repeat(64); + + const listDeferredA = deferred(); + const listDeferredB = deferred(); + + // First call returns A's deferred; subsequent calls return B's. + let callCount = 0; + setIpcHandler("admin_list_reports", () => { + callCount += 1; + if (callCount === 1) return listDeferredA.promise; + return listDeferredB.promise; + }); + + const { container, doRender, unmount } = mountPanel({ + origin: originA, + pubkey: pubkeyA, + }); + + // Render with A — list query starts and stays pending (no settle; would hang). + await act(async () => { + await doRender({ origin: originA, pubkey: pubkeyA }); + await new Promise((r) => setTimeout(r, 0)); + }); + + // Switch to B — triggers generation bump + effect cleanup (active = false for A). + // Re-render causes the effect to re-run with B's deps. + await act(async () => { + await doRender({ origin: originB, pubkey: pubkeyB }); + await new Promise((r) => setTimeout(r, 0)); + }); + + // Now resolve A's stale list with a distinct marker item. + listDeferredA.resolve([ + { + id: "00000000-0000-0000-0000-000000000001", + communityId: "00000000-0000-0000-0000-000000000002", + communityHost: "relay.example.com", + reportEventId: "aabb", + reporterPubkey: "ccdd", + targetKind: "message", + target: "eeff", + reportType: "spam", + status: "STALE-A-RESULT", + createdAt: "2024-01-01T00:00:00Z", + }, + ]); + + // Flush A's resolution — active is false so it must not commit. + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + + // A's stale result must not appear — active flag was false. + const text = container.textContent ?? ""; + assert.ok( + !text.includes("STALE-A-RESULT"), + `stale list result from A must not appear after B renders; got: ${text.slice(0, 300)}`, + ); + + // Resolve B's list — this one is live. + listDeferredB.resolve([ + { + id: "00000000-0000-0000-0000-000000000003", + communityId: "00000000-0000-0000-0000-000000000004", + communityHost: "relay.example.com", + reportEventId: "1122", + reporterPubkey: "3344", + targetKind: "message", + target: "5566", + reportType: "feedback", + status: "LIVE-B-RESULT", + createdAt: "2024-01-02T00:00:00Z", + }, + ]); + + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + + const textAfter = container.textContent ?? ""; + assert.ok( + textAfter.includes("LIVE-B-RESULT"), + `B's live list result must appear; got: ${textAfter.slice(0, 300)}`, + ); + + await unmount(); +}); + +// detail-navigation and attachment-unmount (useAsyncLoad active flag, +// AttachmentViewer loadGenRef cleanup) are covered by +// adminConsolePanelEvents.jsdom-test.mjs where fireEvent dispatches native +// events through React 19's container-level delegation. + +// ── disabled-mode mounts panel ──────────────────────────────────────────── + +test("disabled-probe-mounts-panel: admin-console-panel renders when probe state is disabled", async () => { + // Pinning test for item 1 render-gate fix. + // + // Verifies that a `disabled` probe result (relay serves admin API without + // credential) causes AdminConsolePanel to mount, with the disabled badge + // still visible alongside the panel. + // + // Fails if the render gate is reverted to `authorized`-only: + // isPanelVisible = probeUiState.kind === "authorized" && savedOrigin !== null + // → disabled state never mounts the panel and this test goes red. + + const pubkey = "f".repeat(64); + const savedOrigin = "https://admin.example.com"; + + setIpcHandler("get_admin_origin", () => Promise.resolve(savedOrigin)); + setIpcHandler("admin_probe", () => Promise.resolve({ state: "disabled" })); + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const qc = makeQueryClient(pubkey); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(50); + + const panel = container.querySelector("[data-testid='admin-console-panel']"); + assert.ok( + panel !== null, + "admin-console-panel must mount when probe state is disabled — render gate missing", + ); + + // The disabled badge must still appear above the panel. + const text = container.textContent ?? ""; + assert.ok( + text.includes("Auth is disabled"), + `disabled badge must remain visible; got: ${text.slice(0, 300)}`, + ); + + await unmount(); +}); + +test("authorized-probe-mounts-panel: admin-console-panel still renders when probe state is authorized", async () => { + // Regression guard: changing the render gate must not break the authorized case. + + const pubkey = "9".repeat(64); + const savedOrigin = "https://admin-auth.example.com"; + + setIpcHandler("get_admin_origin", () => Promise.resolve(savedOrigin)); + setIpcHandler("admin_probe", () => + Promise.resolve({ state: "nip98Authorized" }), + ); + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const qc = makeQueryClient(pubkey); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(50); + + const panel = container.querySelector("[data-testid='admin-console-panel']"); + assert.ok( + panel !== null, + "admin-console-panel must still mount when probe state is authorized", + ); + + await unmount(); +}); + +// ── denied badge copy button ────────────────────────────────────────────── + +test("denied-badge-copy-button: copy button is present next to the denied pubkey", async () => { + // Verifies item 2: the pubkey in the denied state is displayed alongside + // a copy button (data-testid="admin-denied-pubkey-copy"), not just a + // cursor-pointer select-all code block. + + const pubkey = "4".repeat(64); + const savedOrigin = "https://admin-denied.example.com"; + + setIpcHandler("get_admin_origin", () => Promise.resolve(savedOrigin)); + setIpcHandler("admin_probe", () => Promise.resolve({ state: "nip98Denied" })); + + const qc = makeQueryClient(pubkey); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(30); + + const pubkeyEl = container.querySelector( + "[data-testid='admin-denied-pubkey']", + ); + assert.ok(pubkeyEl !== null, "admin-denied-pubkey element must be present"); + assert.ok( + pubkeyEl.textContent?.includes(pubkey), + `denied pubkey element must contain the pubkey; got: ${pubkeyEl.textContent}`, + ); + + const copyBtn = container.querySelector( + "[data-testid='admin-denied-pubkey-copy']", + ); + assert.ok( + copyBtn !== null, + "admin-denied-pubkey-copy button must be present — copy-icon pattern missing", + ); + + await unmount(); +}); + +// ── structured detail layouts ───────────────────────────────────────────── +// +// Tests for report-detail-renders-structured-fields and +// feedback-detail-renders-structured-fields live in +// adminConsolePanelEvents.jsdom-test.mjs — they require fireEvent.click +// (React 19's container-level event delegation) which is only available +// in the jsdom suite. + +// ── probe role/source badge ─────────────────────────────────────────────── + +test("probe-role-source-badge: operator role and config source render in panel when probe returns them", async () => { + // Verifies that AdminConsolePanel renders role+source badges when the probe + // returns nip98Authorized with role/source populated. + // + // Mutation evidence: remove role/source from AdminProbeResult → badges absent → red. + + const pubkey = "b1".repeat(32); + const savedOrigin = "https://admin-role.example.com"; + + setIpcHandler("get_admin_origin", () => Promise.resolve(savedOrigin)); + setIpcHandler("admin_probe", () => + Promise.resolve({ + state: "nip98Authorized", + role: "operator", + source: "config", + }), + ); + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const qc = makeQueryClient(pubkey); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(50); + + const text = container.textContent ?? ""; + assert.ok( + text.includes("operator"), + `role badge "operator" must render; got: ${text.slice(0, 300)}`, + ); + assert.ok( + text.includes("config"), + `source badge "config" must render; got: ${text.slice(0, 300)}`, + ); + + await unmount(); +}); + +test("probe-moderator-role: moderator role renders without staffing tab", async () => { + // A moderator should see their role badge but NOT the Staffing tab. + const pubkey = "c2".repeat(32); + const savedOrigin = "https://admin-mod.example.com"; + + setIpcHandler("get_admin_origin", () => Promise.resolve(savedOrigin)); + setIpcHandler("admin_probe", () => + Promise.resolve({ + state: "nip98Authorized", + role: "moderator", + source: "db", + }), + ); + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const qc = makeQueryClient(pubkey); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(50); + + const text = container.textContent ?? ""; + assert.ok( + text.includes("moderator"), + `role "moderator" must render; got: ${text.slice(0, 300)}`, + ); + // Staffing tab must NOT be present for a moderator. + const staffingTab = container.querySelector( + "[data-testid='admin-tab-staffing']", + ); + assert.equal( + staffingTab, + null, + "Staffing tab must not render for moderator role", + ); + + await unmount(); +}); + +test("probe-operator-role: staffing tab renders for operator role", async () => { + // An operator should see the Staffing tab. + const pubkey = "d3".repeat(32); + const savedOrigin = "https://admin-operator.example.com"; + + setIpcHandler("get_admin_origin", () => Promise.resolve(savedOrigin)); + setIpcHandler("admin_probe", () => + Promise.resolve({ + state: "nip98Authorized", + role: "operator", + source: "config", + }), + ); + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const qc = makeQueryClient(pubkey); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(50); + + const staffingTab = container.querySelector( + "[data-testid='admin-tab-staffing']", + ); + assert.ok(staffingTab !== null, "Staffing tab must render for operator role"); + + await unmount(); +}); + +test("probe-no-role: disabled-mode panel renders without role badge", async () => { + // disabled probe has no role/source — panel renders but no badge. + const pubkey = "e4".repeat(32); + const savedOrigin = "https://admin-disabled.example.com"; + + setIpcHandler("get_admin_origin", () => Promise.resolve(savedOrigin)); + setIpcHandler("admin_probe", () => Promise.resolve({ state: "disabled" })); + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const qc = makeQueryClient(pubkey); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(50); + + const panel = container.querySelector("[data-testid='admin-console-panel']"); + assert.ok(panel !== null, "panel must render in disabled mode"); + + // No staffing tab (no role = no operator). + const staffingTab = container.querySelector( + "[data-testid='admin-tab-staffing']", + ); + assert.equal( + staffingTab, + null, + "Staffing tab must not render in disabled mode", + ); + + await unmount(); +}); + +// ── action matrix: allowedActionsForTargetKind ──────────────────────────── + +// Note: allowedActionsForTargetKind is a pure function tested inline via the +// rendered action buttons in adminConsolePanelEvents.jsdom-test.mjs. +// Here we test the API-level types are correct. + +test("action-matrix-types: AdminReportAction type covers all matrix cells", () => { + // Compile-time coverage: if resolveAdminReport is removed or its signature + // changes, tsc fails. Runtime coverage: the static import above proves the + // function is exported and callable. + assert.equal(typeof resolveAdminReport, "function"); +}); diff --git a/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs b/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs new file mode 100644 index 00000000000..655d5404603 --- /dev/null +++ b/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs @@ -0,0 +1,3628 @@ +/** + * Event-driven behavior tests for AdminConsoleSettingsCard / + * AdminConsoleSettingsSession and AdminConsolePanel. + * + * This file runs with jsdom pre-installed (via --import ./test-jsdom-setup.mjs) + * so React 19's canUseDOM is true and isInputEventSupported is set correctly. + * fireEvent from @testing-library/react dispatches native events that travel + * through React 19's container-level event delegation, reaching production + * handlers. + * + * What these tests prove — they fail if: + * - `abortAndResetProbe()` is removed from input onChange + * → origin-edit goes red (stale probe commits, panel renders) + * - `sessionTokenRef` check is removed from handleSave + * → same-session-save-race goes red (stale save clobbers B's input) + * - `active = false` cleanup is removed from useAsyncLoad + * → detail-navigation goes red (stale detail commits) + * - `expectedPubkey` dropped from the set_admin_origin invocation path + * → cross-identity-delayed-save goes red (A's save lacks expectedPubkey) + * - unmount-cleanup effect removed (sessionTokenRef not nulled on unmount) + * → strict-mode-save goes red (StrictMode double-mount silently disables saves) + * + * What these tests also prove: + * - `loadGenRef.current += 1` cleanup removed from AttachmentViewer + * → blob-leak-on-back-navigation goes red (stale blob leaks without revocation) + * Note: the existing attachment-unmount test exercises the same guard but via + * origin/pubkey re-render which also updates originRef/pubkeyRef. The back- + * navigation test isolates loadGenRef by unmounting without context change. + * - `pubkeyHex ? : null` render gate removed + * → authorized-logout-teardown goes red (empty-pubkey session renders, input present) + * Note: this test lives in adminConsolePanel.test.mjs (MinimalDocument suite) because + * the jsdom React 19 global scheduler leaves pending promises when the gate is absent, + * causing the jsdom test runner to report CANCELLED instead of a clean AssertionError. + */ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +// ── Tauri IPC interceptor ──────────────────────────────────────────────────── +// +// @tauri-apps/api/core calls `window.__TAURI_INTERNALS__.invoke(...)` where +// `window` is the jsdom window object (set via test-jsdom-setup.mjs), not +// `globalThis`. Both globalThis.__TAURI_INTERNALS__ and window.__TAURI_INTERNALS__ +// must be set so all import paths reach the same mock. + +/** @type {Map Promise>} */ +const ipcHandlers = new Map(); + +function setIpcHandler(cmd, fn) { + ipcHandlers.set(cmd, fn); +} +function clearIpcHandlers() { + ipcHandlers.clear(); +} + +const tauriMock = { + invoke(cmd, args) { + const handler = ipcHandlers.get(cmd); + if (handler) return handler(args); + return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); + }, + transformCallback(_cb) { + return Math.random(); + }, +}; +// Set on both globalThis and the jsdom window object so all access paths work. +globalThis.__TAURI_INTERNALS__ = tauriMock; +if (globalThis.window && globalThis.window !== globalThis) { + globalThis.window.__TAURI_INTERNALS__ = tauriMock; +} + +// ── Production imports ─────────────────────────────────────────────────────── + +import React from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import { fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { AdminConsoleSettingsCard } from "./AdminConsoleSettingsCard.tsx"; +import { AdminConsolePanel } from "./AdminConsolePanel.tsx"; + +// ── Success-toast capture ──────────────────────────────────────────────────── +// +// sonner's `toast` is a shared singleton object across import paths (verified), +// so replacing `toast.success` here is observed by the production components. +// Captured messages are asserted by the toast tests and cleared in afterEach. + +/** @type {string[]} */ +const capturedToasts = []; +toast.success = (msg) => { + capturedToasts.push(String(msg)); + return 0; +}; + +/** @type {string[]} */ +const capturedErrorToasts = []; +toast.error = (msg) => { + capturedErrorToasts.push(String(msg)); + return 0; +}; + +// ── Typed native mutation error ────────────────────────────────────────────── +// +// Admin mutation commands reject with a serialized Rust `AdminMutationError` +// (`{message, relayStatus, bodyComplete}`, camelCase). The real tauri bridge +// rejects with that plain object and `toTauriError` wraps it into a +// `TauriInvokeError` whose `.message` is the message and `.payload` is the +// whole object — from which the UI reads `relayStatus`/`bodyComplete` to decide +// idempotency-retry policy. Rejecting with a plain object here (NOT an Error) +// reproduces that wire shape exactly. +// +// `relayStatus` is a number when the relay authoritatively answered, and `null` +// for a transport/pre-send failure where no relay verdict exists. `bodyComplete` +// is true only when the relay's full body was read; it defaults to `relayStatus +// !== null` (a status with a fully-read body — the common authoritative case), +// and callers pass `false` explicitly to model a truncated/lost-body response. +function mutationReject( + message, + relayStatus, + bodyComplete = relayStatus !== null, +) { + return Promise.reject({ message, relayStatus, bodyComplete }); +} + +// ── Deferred promise helper ────────────────────────────────────────────────── + +function deferred() { + let resolve, reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +// ── Mount helpers ──────────────────────────────────────────────────────────── + +function makeQueryClient(pubkeyHex) { + // gcTime: Infinity prevents React Query from garbage-collecting setQueryData + // entries before the component mounts its observer. gcTime: 0 races with + // the GC timer and is appropriate only for test teardown, not setup. + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + // Always set identity data (even for empty pubkey) so React Query never calls + // queryFn = getIdentity (which would hit the unmocked IPC). + // Component reads pubkeyHex = identity?.pubkey ?? "" — so { pubkey: "" } + // produces pubkeyHex = "" which is the correct logged-out representation. + qc.setQueryData(["identity"], { pubkey: pubkeyHex }); + return qc; +} + +function mountCard(qc) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const doRender = async () => { + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: qc }, + React.createElement(AdminConsoleSettingsCard), + ), + ); + }); + }; + const unmount = async () => { + await act(async () => { + root.unmount(); + }); + document.body.removeChild(container); + }; + return { container, doRender, unmount }; +} + +function mountPanel({ origin, pubkey }) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const doRender = async ({ origin: o, pubkey: p } = { origin, pubkey }) => { + await act(async () => { + root.render( + React.createElement(AdminConsolePanel, { origin: o, pubkey: p }), + ); + }); + }; + const unmount = async () => { + await act(async () => { + root.unmount(); + }); + document.body.removeChild(container); + }; + return { container, doRender, unmount }; +} + +async function settle(ms = 20) { + await act(async () => { + await new Promise((r) => setTimeout(r, ms)); + }); +} + +afterEach(() => { + clearIpcHandlers(); + capturedToasts.length = 0; + capturedErrorToasts.length = 0; +}); + +// ── origin-edit ────────────────────────────────────────────────────────────── + +test("origin-edit: input change while probe in-flight discards stale probe result", async () => { + // Verifies that abortAndResetProbe() is wired to input onChange. + // + // Scenario: + // 1. Component mounts with a saved origin; initial probe resolves + // immediately to "disabled" (no panel rendered, no unmocked IPC). + // 2. User clicks Re-probe — new deferred probe starts. + // 3. User edits the input via fireEvent.change — onChange fires, calls + // abortAndResetProbe(), setting probeAbortRef.current.signal.aborted. + // 4. Stale probe resolves — the callback sees signal.aborted and returns + // early; probeUiState stays at { kind: "idle" } → panel never renders. + // + // Fails if abortAndResetProbe() is removed from the onChange handler: + // the stale probe commits "nip98Authorized" and the panel renders. + + const pubkey = "d".repeat(64); + const savedOrigin = "https://admin.example.com"; + + setIpcHandler("get_admin_origin", () => Promise.resolve(savedOrigin)); + setIpcHandler("admin_probe", () => Promise.resolve({ state: "disabled" })); + // If the stale probe commits nip98Authorized, the admin panel would render + // and call these IPC commands. Mock them so the test doesn't hang. + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const qc = makeQueryClient(pubkey); + const { container, doRender } = mountCard(qc); + await doRender(); + await settle(25); + + // Re-probe button appears when savedOrigin is set. + const reprobe = container.querySelector( + "[data-testid='admin-probe-refresh']", + ); + assert.ok(reprobe, "re-probe button must appear when savedOrigin is set"); + + // Start a new deferred probe. + const probeDeferred = deferred(); + setIpcHandler("admin_probe", () => probeDeferred.promise); + + await act(async () => { + // fireEvent.click dispatches a native click — React's delegated onClick handler + // calls runProbe(), creating a new AbortController on probeAbortRef.current. + fireEvent.click(reprobe); + await new Promise((r) => setTimeout(r, 5)); + }); + + // Edit the input while the probe is in-flight. fireEvent.change dispatches + // a native change event through React 19's container-level delegation, + // reaching the production onChange handler which calls abortAndResetProbe(). + const input = container.querySelector("[data-testid='admin-origin-input']"); + assert.ok(input, "origin input must be present"); + + await act(async () => { + fireEvent.change(input, { + target: { value: "https://admin-new.example.com" }, + }); + await new Promise((r) => setTimeout(r, 5)); + }); + + // Resolve the stale probe — controller.signal.aborted is true because + // abortAndResetProbe() was called by onChange. The callback returns early. + // We resolve inside act() so React flushes the state update synchronously. + await act(async () => { + probeDeferred.resolve({ state: "nip98Authorized" }); + await new Promise((r) => setTimeout(r, 20)); + }); + + // The panel must NOT be visible — probeUiState is { kind: "idle" }, not + // "authorized". The stale nip98Authorized result was discarded. + const panel = container.querySelector("[data-testid='admin-console-panel']"); + assert.ok( + panel === null, + "admin-console-panel must not render — stale probe discarded after onChange", + ); + const text = container.textContent ?? ""; + assert.ok( + !text.includes("Connected"), + `stale nip98Authorized must not commit; got: ${text.slice(0, 200)}`, + ); + + // Skip unmount() here — calling act(root.unmount) after a mutation-caused + // panel render would hang waiting for React cleanup. The assertions already + // proved the test. The afterEach clears IPC handlers; the container is GC'd. +}); + +// ── same-session save race ──────────────────────────────────────────────────── + +test("same-session-save-race: deferred save X does not clobber pending save Y", async () => { + // Verifies the sessionTokenRef fence in handleSave. + // + // The save button is disabled while isSaving=true. We use fireEvent.keyDown + // with Enter on the input to trigger handleSave() directly (via onKeyDown), + // bypassing the disabled save button. This lets both saves be in-flight + // simultaneously — each with its own sessionToken. + // + // Scenario: + // 1. Type X and press Enter — save X starts (deferred), token=X. + // 2. Type Y and press Enter while X is pending — save Y starts (deferred), + // token=Y replaces X's token on sessionTokenRef.current. + // 3. Resolve X late: token(X) != sessionTokenRef.current(Y) → returns early, + // no runProbe(originX). + // 4. Resolve Y: runProbe(originY) fires normally. + // + // Fails if sessionTokenRef checks are removed: X's continuation calls + // runProbe(originX) after Y has set its token, causing probeOrigins to + // contain originX. + + const pubkey = "e".repeat(64); + const originX = "https://admin-x.example.com"; + const originY = "https://admin-y.example.com"; + + setIpcHandler("get_admin_origin", () => Promise.resolve(null)); + + let resolveX, resolveY; + let saveCount = 0; + setIpcHandler("set_admin_origin", () => { + saveCount += 1; + if (saveCount === 1) + return new Promise((r) => { + resolveX = r; + }); + return new Promise((r) => { + resolveY = r; + }); + }); + + // Track probe origins to detect if X erroneously fires a probe. + const probeOrigins = []; + setIpcHandler("admin_probe", (args) => { + probeOrigins.push(args?.origin ?? "(none)"); + return Promise.resolve({ state: "disabled" }); + }); + + const qc = makeQueryClient(pubkey); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(15); + + const input = container.querySelector("[data-testid='admin-origin-input']"); + assert.ok(input, "input must be present"); + + // Type X and press Enter to start save X (deferred). + await act(async () => { + fireEvent.change(input, { target: { value: originX } }); + await new Promise((r) => setTimeout(r, 5)); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", keyCode: 13 }); + await new Promise((r) => setTimeout(r, 5)); + }); + + // X's save is now pending (isSaving=true). Type Y and press Enter — this + // calls handleSave() again despite isSaving=true, creating a new token(Y). + await act(async () => { + fireEvent.change(input, { target: { value: originY } }); + await new Promise((r) => setTimeout(r, 5)); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", keyCode: 13 }); + await new Promise((r) => setTimeout(r, 5)); + }); + + // Both saves are now in-flight. Clear probes from any initial mount probes. + probeOrigins.length = 0; + + // Resolve X late. Token(X) != sessionTokenRef.current (Y replaced it). + // With token check: returns early, runProbe(originX) NOT called. + // Without token check: runProbe(originX) IS called -> probeOrigins has originX. + resolveX?.(originX); + await settle(20); + + assert.ok( + !probeOrigins.some((o) => o.includes("admin-x")), + `X's late save must not trigger a probe; probes after X resolved: ${JSON.stringify(probeOrigins)}`, + ); + + // Resolve Y — its probe fires normally with originY. + resolveY?.(originY); + await settle(20); + + assert.ok( + probeOrigins.some((o) => o.includes("admin-y")), + `Y's save must trigger a probe with originY; probes: ${JSON.stringify(probeOrigins)}`, + ); + + await unmount(); +}); + +// ── detail-navigation ──────────────────────────────────────────────────────── + +test("detail-navigation: stale detail result is discarded after navigating away", async () => { + // Verifies useAsyncLoad's effect-local active flag on detail fetch. + // + // Scenario: + // 1. Panel renders; list resolves immediately with one entry. + // 2. User clicks the report row → detail fetch A starts (active=true, + // waiting on detailDeferredA). + // 3. origin/pubkey changes → generation bumps → old effect cleanup: + // active=false. New effect starts → detail fetch B (detailDeferredB). + // 4. detailDeferredA resolves with "STALE-DETAIL-CONTENT" → active=false + // → result discarded. detailDeferredB stays pending → UI shows loading. + // + // Fails if the `active = false` cleanup is removed: fetch A has active=true, + // so "STALE-DETAIL-CONTENT" commits and appears in the DOM. + + const origin = "https://admin.example.com"; + const pubkey = "a".repeat(64); + + const listResult = [ + { + id: "00000000-0000-0000-0000-000000000099", + communityId: "00000000-0000-0000-0000-000000000002", + communityHost: "relay.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + status: "open", + createdAt: "2024-01-01T00:00:00Z", + }, + ]; + + setIpcHandler("admin_list_reports", () => Promise.resolve(listResult)); + + // Two separate deferreds: A for the first (stale) fetch, B for the second. + // This prevents B from accidentally committing A's stale content when the + // deferred is shared. + const detailDeferredA = deferred(); + const detailDeferredB = deferred(); + let detailCallCount = 0; + setIpcHandler("admin_get_report", () => { + detailCallCount += 1; + return detailCallCount === 1 + ? detailDeferredA.promise + : detailDeferredB.promise; + }); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + + // Initial render + list resolution. + await act(async () => { + await doRender(); + await new Promise((r) => setTimeout(r, 30)); + }); + + // Find a report row button and click via fireEvent. + const allButtons = container.querySelectorAll("button"); + let clickedReport = false; + for (const btn of allButtons) { + const testid = btn.getAttribute("data-testid") ?? ""; + if (testid.startsWith("admin-tab")) continue; + await act(async () => { + fireEvent.click(btn); + await new Promise((r) => setTimeout(r, 0)); + }); + clickedReport = true; + break; + } + + assert.ok(clickedReport, "a report row button must exist and be clickable"); + + // Detail fetch A is in-flight (active=true). Change origin/pubkey → + // generation bumps → old effect cleanup: active=false. New effect starts + // (active=true) and calls admin_get_report → detailDeferredB. + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + await act(async () => { + await doRender({ + origin: "https://admin-2.example.com", + pubkey: "b".repeat(64), + }); + await new Promise((r) => setTimeout(r, 5)); + }); + + // Resolve stale fetch A. Its active=false → result discarded. + detailDeferredA.resolve({ + id: "00000000-0000-0000-0000-000000000099", + content: "STALE-DETAIL-CONTENT", + status: "STALE-DETAIL", + }); + + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + + const text = container.textContent ?? ""; + assert.ok( + !text.includes("STALE-DETAIL-CONTENT"), + `stale detail A must not appear (active=false); got: ${text.slice(0, 300)}`, + ); + + // Clean up: resolve B to avoid dangling promises. + detailDeferredB.resolve({ id: "skip", content: "done" }); + await act(async () => { + await new Promise((r) => setTimeout(r, 5)); + }); + + await unmount(); +}); + +// ── attachment-unmount ─────────────────────────────────────────────────────── + +test("attachment-unmount: late blob URL is revoked and not committed after panel generation changes", async () => { + // Verifies AttachmentViewer's loadGenRef cleanup and per-load generation guard. + // + // Scenario comments updated for auto-load behavior: + // 1. Panel renders; Feedback tab clicked; list+detail resolve immediately. + // 2. "View attachment" button appears (non-image mime, no auto-load); user + // clicks it — load starts: thisGen = ++loadGenRef.current = 1. Fetch deferred. + // 3. Re-render with new origin/pubkey bumps panelGeneration → + // AttachmentViewer cleanup: loadGenRef.current += 1 = 2. originRef and + // pubkeyRef also update to the new values. + // 4. Attachment resolves: thisGen(1) !== loadGenRef.current(2) (and also + // thisOrigin !== originRef.current) — URL.revokeObjectURL called, + // setBlobUrl NOT called. + // + // Uses application/pdf (non-image) so the attachment doesn't auto-load on + // mount — the load is triggered by the "View attachment" button click, keeping + // the scenario identical to the original test design. + + const origin = "https://admin.example.com"; + const pubkey = "a".repeat(64); + const sha256 = "a".repeat(64); + + const feedbackSummary = { + id: "00000000-0000-0000-0000-000000000011", + communityId: "00000000-0000-0000-0000-000000000022", + communityHost: "relay.example.com", + submitterPubkey: "submitterattach001", + category: null, + bodySummary: "Test feedback summary", + receivedAt: "2024-01-01T00:00:01Z", + }; + + const feedbackDetail = { + id: "00000000-0000-0000-0000-000000000011", + communityId: "00000000-0000-0000-0000-000000000022", + communityHost: "relay.example.com", + eventId: "attachtest001", + submitterPubkey: "submitterattach001", + category: null, + body: "Test feedback full body", + tags: [ + [ + "imeta", + `url https://relay.example.com/files/${sha256}`, + "m application/pdf", + `x ${sha256}`, + "size 1000", + ], + ], + eventCreatedAt: "2024-01-01T00:00:00Z", + receivedAt: "2024-01-01T00:00:01Z", + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => + Promise.resolve([feedbackSummary]), + ); + setIpcHandler("admin_get_feedback", () => Promise.resolve(feedbackDetail)); + + const attachDeferred = deferred(); + const revokedUrls = []; + const origRevoke = globalThis.URL?.revokeObjectURL; + if (!globalThis.URL) globalThis.URL = {}; + globalThis.URL.revokeObjectURL = (url) => { + revokedUrls.push(url); + if (origRevoke) origRevoke.call(globalThis.URL, url); + }; + globalThis.URL.createObjectURL = () => "blob:test-url"; + setIpcHandler( + "admin_fetch_feedback_attachment", + () => attachDeferred.promise, + ); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + + await act(async () => { + await doRender(); + await new Promise((r) => setTimeout(r, 30)); + }); + + // Click the Feedback tab via fireEvent. + const feedbackTab = container.querySelector( + "[data-testid='admin-tab-feedback']", + ); + assert.ok(feedbackTab, "Feedback tab button must be present"); + await act(async () => { + fireEvent.click(feedbackTab); + await new Promise((r) => setTimeout(r, 30)); + }); + + // Navigate to feedback detail, then wait for the auto-load to start. + // Image attachments now auto-load on AttachmentViewer mount — no "View + // attachment" click required; the load kicks off as soon as FeedbackDetail + // renders the AttachmentViewer. + let startedAttachmentLoad = false; + const allBtns = container.querySelectorAll("button"); + for (const btn of allBtns) { + const testid = btn.getAttribute("data-testid") ?? ""; + if (testid.startsWith("admin-tab")) continue; + // Click feedback item to navigate to detail. + await act(async () => { + fireEvent.click(btn); + await new Promise((r) => setTimeout(r, 30)); + }); + // For non-image MIME (application/pdf), a "View attachment" button appears. + // Click it to start the load. + for (const b of container.querySelectorAll("button")) { + if ((b.textContent ?? "").includes("View attachment")) { + await act(async () => { + fireEvent.click(b); + await new Promise((r) => setTimeout(r, 0)); + }); + startedAttachmentLoad = true; + break; + } + } + break; + } + + assert.ok( + startedAttachmentLoad, + '"View attachment" button must be found and clicked for non-image attachment', + ); + + // Attachment fetch is in-flight (deferred). Change origin/pubkey to bump + // panelGeneration — triggers AttachmentViewer cleanup: loadGenRef.current += 1. + // The new panel renders but the user hasn't clicked "View attachment" again, + // so loadGenRef.current on the now-unmounted instance's ref = original+1. + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + await act(async () => { + await doRender({ + origin: "https://admin-2.example.com", + pubkey: "b".repeat(64), + }); + await new Promise((r) => setTimeout(r, 0)); + }); + + // Resolve the attachment fetch. With the cleanup increment: + // thisGen(1) !== loadGenRef.current(2) -> revoke, no blob committed. + // Without the cleanup increment: + // thisGen(1) == loadGenRef.current(1) AND thisOrigin(admin.example.com) + // !== originRef.current(admin-2.example.com) -> still revoke (origin check). + // So this test catches the mutation only if the origin/pubkey check is also + // removed. The loadGenRef test is most meaningful for detecting same-context + // concurrent loads — see the comment above. We include it here as defense- + // in-depth: if both loadGenRef AND the origin check were removed, the stale + // blob would commit. + attachDeferred.resolve(new ArrayBuffer(8)); + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + + const img = container.querySelector("img"); + assert.equal( + img?.getAttribute("src") ?? null, + null, + "stale blob URL must not be committed to an img element after panel generation change", + ); + + if (origRevoke !== undefined) globalThis.URL.revokeObjectURL = origRevoke; + await unmount(); +}); + +// ── blob-leak-on-back-navigation ────────────────────────────────────────────────────────────── + +test("blob-leak-on-back-navigation: loadGenRef cleanup prevents orphaned blob URL", async () => { + // Isolates the loadGenRef.current += 1 cleanup in AttachmentViewer. + // + // Scenario: attachment fetch is in-flight, then the user navigates "Back to + // feedback" (onBack sets selectedId=null in FeedbackTab, unmounting + // FeedbackDetail and AttachmentViewer). At unmount the cleanup fires: + // loadGenRef.current += 1 ← MUTATION TARGET + // The late fetch resolves. Since origin/pubkey are UNCHANGED (no context + // change happened), only the loadGenRef check catches the mismatch: + // thisGen (pre-cleanup value) !== loadGenRef.current (incremented) → revoke + // + // Without the cleanup increment: + // thisGen === loadGenRef.current (both remain at 1) → all three guards pass + // → setBlobUrl called → blob URL committed to blobUrlRef.current with no + // revocation → orphaned blob URL leak. + // + // Fails if loadGenRef.current += 1 is removed from the cleanup. + + const origin = "https://admin.example.com"; + const pubkey = "a".repeat(64); + const sha256 = "a".repeat(64); + + const feedbackSummary = { + id: "00000000-0000-0000-0000-000000000011", + communityId: "00000000-0000-0000-0000-000000000022", + communityHost: "relay.example.com", + submitterPubkey: "submitterblobtest001", + category: null, + bodySummary: "Test feedback summary", + receivedAt: "2024-01-01T00:00:01Z", + }; + + const feedbackDetail = { + id: "00000000-0000-0000-0000-000000000011", + communityId: "00000000-0000-0000-0000-000000000022", + communityHost: "relay.example.com", + eventId: "blobtest001", + submitterPubkey: "submitterblobtest001", + category: null, + body: "Test feedback full body", + tags: [ + [ + "imeta", + `url https://relay.example.com/files/${sha256}`, + "m image/png", + `x ${sha256}`, + "size 1000", + ], + ], + eventCreatedAt: "2024-01-01T00:00:00Z", + receivedAt: "2024-01-01T00:00:01Z", + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => + Promise.resolve([feedbackSummary]), + ); + setIpcHandler("admin_get_feedback", () => Promise.resolve(feedbackDetail)); + + const attachDeferred = deferred(); + const revokedUrls = []; + const origRevoke = globalThis.URL?.revokeObjectURL; + if (!globalThis.URL) globalThis.URL = {}; + globalThis.URL.revokeObjectURL = (url) => { + revokedUrls.push(url); + if (origRevoke) origRevoke.call(globalThis.URL, url); + }; + globalThis.URL.createObjectURL = () => "blob:back-nav-test-url"; + setIpcHandler( + "admin_fetch_feedback_attachment", + () => attachDeferred.promise, + ); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + + await act(async () => { + await doRender(); + await new Promise((r) => setTimeout(r, 30)); + }); + + // Click Feedback tab. + const feedbackTab = container.querySelector( + "[data-testid='admin-tab-feedback']", + ); + assert.ok(feedbackTab, "Feedback tab must be present"); + await act(async () => { + fireEvent.click(feedbackTab); + await new Promise((r) => setTimeout(r, 30)); + }); + + // Navigate to feedback detail. Image attachments auto-load on mount, + // so navigating to the detail starts the load immediately — no "View + // attachment" click needed. + let navigatedToDetail = false; + for (const btn of container.querySelectorAll("button")) { + const testid = btn.getAttribute("data-testid") ?? ""; + if (testid.startsWith("admin-tab")) continue; + await act(async () => { + fireEvent.click(btn); + await new Promise((r) => setTimeout(r, 30)); + }); + navigatedToDetail = true; + break; + } + assert.ok( + navigatedToDetail, + "must navigate to feedback detail and start attachment load", + ); + + // Attachment fetch is now in-flight. Click "Back to feedback" — this + // unmounts FeedbackDetail (and AttachmentViewer within it) WITHOUT changing + // origin or pubkey. The cleanup fires: loadGenRef.current += 1. + const backBtn = Array.from(container.querySelectorAll("button")).find((b) => + (b.textContent ?? "").includes("Back to feedback"), + ); + assert.ok( + backBtn, + "'Back to feedback' button must be present while detail is showing", + ); + await act(async () => { + fireEvent.click(backBtn); + await new Promise((r) => setTimeout(r, 5)); + }); + + // Resolve the attachment fetch. With cleanup increment: + // thisGen (1) !== loadGenRef.current (2) → URL.revokeObjectURL("blob:back-nav-test-url") + // Without cleanup increment: + // thisGen (1) === loadGenRef.current (1) AND origin/pubkey unchanged + // → setBlobUrl called → orphaned blob, no revocation. + attachDeferred.resolve(new ArrayBuffer(8)); + await act(async () => { + await new Promise((r) => setTimeout(r, 30)); + }); + + assert.ok( + revokedUrls.includes("blob:back-nav-test-url"), + `blob URL must be revoked on back-navigation; revokedUrls: ${JSON.stringify(revokedUrls)}`, + ); + + if (origRevoke !== undefined) globalThis.URL.revokeObjectURL = origRevoke; + await unmount(); +}); + +// ── cross-identity delayed save ─────────────────────────────────────────────── + +test("cross-identity-delayed-save: A's late save carries A's expectedPubkey and does not touch B's state", async () => { + // Verifies that set_admin_origin IPC is called with expectedPubkey = A's pubkey, + // and that A's late save completion does not alter B's component state. + // + // The cross-session boundary is enforced by key={pubkeyHex}: when pubkey changes, + // A's component unmounts and B's mounts fresh. A's deferred save resolves and + // its continuation calls runProbe — but React state updates on the unmounted A + // component are discarded. B's input and panel are unaffected. + // + // Scenario: + // 1. Mount with pubkeyA; drive to authorized (probe nip98Authorized, panel rendered). + // 2. Edit input and start save — deferred set_admin_origin with expectedPubkey=A. + // 3. Switch identity to pubkeyB while A's save is pending: + // - A's component is synchronously unmounted (key change). + // - B's component mounts fresh with no saved origin. + // 4. Resolve A's deferred save late. + // 5. Assert: + // a. The set_admin_origin call recorded expectedPubkey = pubkeyA. + // b. B's input is still empty (A's late state writes discarded by React). + // c. B's panel does not show A's origin as authorized. + // d. No admin_probe fires for A's origin after the identity switch. + // + // Fails if expectedPubkey is dropped from the set_admin_origin invocation path + // (api.ts forwarding): the recorded call has no expectedPubkey, so the Rust-level + // guard cannot enforce identity isolation. + + const pubkeyA = "a".repeat(64); + const pubkeyB = "b".repeat(64); + const originA = "https://admin-a.example.com"; + const newOriginA = "https://admin-a-new.example.com"; + + // Saved origin for A; B has none. + setIpcHandler("get_admin_origin", (args) => { + if (args?.expectedPubkey === pubkeyA) return Promise.resolve(originA); + return Promise.resolve(null); + }); + // Initial probe for A → authorized so the panel renders. + setIpcHandler("admin_probe", () => + Promise.resolve({ state: "nip98Authorized" }), + ); + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const qc = makeQueryClient(pubkeyA); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(30); + + // A is authorized — input must show originA. + const inputA = container.querySelector("[data-testid='admin-origin-input']"); + assert.ok(inputA, "input must render for pubkeyA"); + + // Record all set_admin_origin calls. + const saveRecords = []; + let resolveSaveA; + setIpcHandler("set_admin_origin", (args) => { + saveRecords.push({ ...args }); + return new Promise((r) => { + resolveSaveA = r; + }); + }); + + // Edit input to newOriginA and press Enter to start a deferred save. + await act(async () => { + fireEvent.change(inputA, { target: { value: newOriginA } }); + await new Promise((r) => setTimeout(r, 5)); + }); + await act(async () => { + fireEvent.keyDown(inputA, { key: "Enter", keyCode: 13 }); + await new Promise((r) => setTimeout(r, 5)); + }); + + // A's save is now in-flight (deferred). Switch to pubkeyB. + // A's component is synchronously unmounted (key change). + setIpcHandler("get_admin_origin", (args) => { + if (args?.expectedPubkey === pubkeyB) return Promise.resolve(null); + return Promise.resolve(null); + }); + // After switch, record admin_probe calls to detect any stale A probe firing. + const probeRecords = []; + setIpcHandler("admin_probe", (args) => { + probeRecords.push({ ...args }); + return Promise.resolve({ state: "disabled" }); + }); + await act(async () => { + qc.setQueryData(["identity"], { pubkey: pubkeyB }); + await new Promise((r) => setTimeout(r, 20)); + }); + + // Resolve A's deferred save late. A's component is already unmounted — any + // React state updates from A's continuation are discarded. B remains untouched. + resolveSaveA?.(newOriginA); + await settle(30); + + // (a) The set_admin_origin IPC call must have carried expectedPubkey = pubkeyA. + assert.ok( + saveRecords.length >= 1, + "set_admin_origin must have been called at least once", + ); + assert.equal( + saveRecords[0]?.expectedPubkey, + pubkeyA, + `set_admin_origin must carry expectedPubkey = pubkeyA; got: ${JSON.stringify(saveRecords[0])}`, + ); + + // (b) B's input must still be empty (A's late state writes are discarded by React + // on the unmounted A component; they never reach B's component tree). + const inputB = container.querySelector("[data-testid='admin-origin-input']"); + assert.ok(inputB, "B's input must be present after identity switch"); + assert.equal( + inputB.value, + "", + `B's input must be empty after identity switch; got: "${inputB.value}"`, + ); + + // (c) B's panel must not show A's origin as authorized — B is not authorized. + const panel = container.querySelector("[data-testid='admin-console-panel']"); + assert.equal( + panel, + null, + "admin-console-panel must not render for B — B has no authorized origin", + ); + + // (d) No admin_probe must have fired for A's origin after the identity switch. + // A's handleSave continuation calls runProbe(canonical) after the save resolves. + // The sessionTokenRef check prevents same-session concurrent saves from firing + // a stale probe, but it does not stop A's own continuation after A unmounts: + // A's sessionTokenRef still matches A's token, so the check passes and + // runProbe(newOriginA) fires as an IPC call. React discards the state update + // on the unmounted component, so B is unaffected — but the probe IPC fires. + // This assertion catches any such stale probe call: if a probe with A's origin + // is recorded here, production code is calling probeAdminOrigin after unmount. + const staleProbe = probeRecords.find( + (p) => p?.origin === originA || p?.origin === newOriginA, + ); + assert.equal( + staleProbe, + undefined, + `no admin_probe must fire for A's origin after identity switch; got: ${JSON.stringify(staleProbe)}`, + ); + + await unmount(); +}); + +// ── strict-mode-save ────────────────────────────────────────────────────────── + +test("strict-mode-save: probe fires after save under React.StrictMode double-mount", async () => { + // Verifies the StrictMode-safe unmount fence in AdminConsoleSettingsSession. + // + // React.StrictMode (used in desktop/src/main.tsx) double-invokes effects in + // development: setup → cleanup → setup. An isMountedRef-based fence + // (cleanup sets isMountedRef.current = false, no reset in setup body) leaves + // the ref permanently false after the double-mount, silently killing every + // save completion in dev builds. + // + // The correct fence nulls sessionTokenRef on unmount instead: + // useEffect(() => () => { sessionTokenRef.current = null; }, []) + // StrictMode's cleanup sets sessionTokenRef.current = null, then the setup + // re-runs handleSave's `sessionTokenRef.current = token` when a new save + // starts — so the fence is re-armed per save, not per mount. + // + // Fails if the unmount-cleanup effect is removed (isMountedRef variant or no + // fence): after StrictMode double-mount, handleSave continuation is + // permanently blocked (isMountedRef=false), so probeOrigins stays empty. + + const pubkey = "c".repeat(64); + const savedOrigin = "https://admin-strict.example.com"; + const canonicalOrigin = "https://admin-strict-canonical.example.com"; + + setIpcHandler("get_admin_origin", () => Promise.resolve(savedOrigin)); + + // Track probe invocations to verify the save drives a probe. + const probeOrigins = []; + setIpcHandler("admin_probe", (args) => { + probeOrigins.push(args?.origin ?? "(none)"); + return Promise.resolve({ state: "disabled" }); + }); + setIpcHandler("set_admin_origin", () => Promise.resolve(canonicalOrigin)); + + // gcTime: Infinity is critical: with gcTime: 0 StrictMode's simulated unmount + // GCs the seeded identity query before the component's observer re-subscribes, + // so the input never renders on the second mount. + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }); + qc.setQueryData(["identity"], { pubkey }); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + // Mount under React.StrictMode — triggers setup → cleanup → setup on all effects. + await act(async () => { + root.render( + React.createElement( + React.StrictMode, + null, + React.createElement( + QueryClientProvider, + { client: qc }, + React.createElement(AdminConsoleSettingsCard), + ), + ), + ); + }); + await settle(30); + + const input = container.querySelector("[data-testid='admin-origin-input']"); + assert.ok( + input, + "origin input must render after StrictMode double-mount — identity query not GC'd", + ); + + // Clear probes from the initial mount probe. + probeOrigins.length = 0; + + // Edit input and press Enter to trigger handleSave(). + const newOrigin = "https://admin-strict-new.example.com"; + await act(async () => { + fireEvent.change(input, { target: { value: newOrigin } }); + await new Promise((r) => setTimeout(r, 5)); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", keyCode: 13 }); + await new Promise((r) => setTimeout(r, 5)); + }); + await settle(30); + + // The probe must fire for the canonical origin returned by set_admin_origin. + // Fails if isMountedRef=false (from StrictMode cleanup) permanently blocks + // the handleSave continuation: probeOrigins stays empty. + assert.ok( + probeOrigins.some((o) => o === canonicalOrigin), + `probe must fire after save under StrictMode; probes: ${JSON.stringify(probeOrigins)}`, + ); + + await act(async () => { + root.unmount(); + }); + document.body.removeChild(container); +}); + +// ── structured detail layouts ──────────────────────────────────────────────── + +test("report-detail-renders-structured-fields: ReportDetail shows field layout, not raw JSON", async () => { + // Verifies item 3: the report detail view renders data-testid='report-detail-fields' + // and the status value, not a raw JSON
    .
    +  // Lives here (jsdom) because navigating into a detail requires fireEvent.click
    +  // for React 19's container-level event delegation.
    +  //
    +  // Mutation evidence: revert ReportFields → 
    {JSON.stringify(...)}
    + // → this test goes red ("report-detail-fields element must render"). + + const origin = "https://admin.example.com"; + const pubkey = "5".repeat(64); + + const reportItem = { + id: "00000000-0000-0000-0000-000000000099", + communityId: "00000000-0000-0000-0000-000000000002", + communityHost: "relay.example.com", + reportEventId: "aabb", + reporterPubkey: "ccdd", + targetKind: "event", + target: "eeff", + reportType: "spam", + status: "open", + createdAt: "2024-06-01T12:00:00Z", + }; + + // Full AdminReportDetailDto: includes note, resolvedBy, and a nested message. + const reportDetail = { + ...reportItem, + channelId: "00000000-0000-0000-0000-000000000003", + note: "private moderator note", + resolvedBy: null, + resolvedAt: null, + actionId: null, + message: { + authorPubkey: "aabbccdd", + content: "offensive message text", + createdAt: "2024-05-31T10:00:00Z", + deletedAt: null, + }, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([reportItem])); + setIpcHandler("admin_get_report", () => Promise.resolve(reportDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + + // Navigate into the report detail — click the first non-tab button. + const allButtons = container.querySelectorAll("button"); + for (const btn of allButtons) { + const testid = btn.getAttribute("data-testid") ?? ""; + if (testid.startsWith("admin-tab")) continue; + await act(async () => { + fireEvent.click(btn); + await new Promise((r) => setTimeout(r, 30)); + }); + break; + } + + await settle(30); + + const fields = container.querySelector( + "[data-testid='report-detail-fields']", + ); + assert.ok( + fields !== null, + "report-detail-fields element must render — JSON dump not replaced", + ); + + const text = container.textContent ?? ""; + assert.ok( + text.includes("open"), + `report status 'open' must appear in structured layout; got: ${text.slice(0, 400)}`, + ); + + // Must NOT be rendering JSON.stringify output (e.g. key-colon pairs). + assert.ok( + !text.includes('"status": "open"'), + `raw JSON must not be rendered in report detail; got: ${text.slice(0, 400)}`, + ); + + // Real DTO fields: note and nested message content must appear. + assert.ok( + text.includes("private moderator note"), + `report note must render; got: ${text.slice(0, 600)}`, + ); + assert.ok( + text.includes("offensive message text"), + `nested message content must render; got: ${text.slice(0, 600)}`, + ); + assert.ok( + text.includes("aabbccdd"), + `nested message authorPubkey must render; got: ${text.slice(0, 600)}`, + ); + + // Fake fields must NOT appear. + assert.ok( + !text.includes("reason"), + `invented 'reason' field must not render; got: ${text.slice(0, 400)}`, + ); + assert.ok( + !text.includes("moderationNote"), + `invented 'moderationNote' field must not render; got: ${text.slice(0, 400)}`, + ); + + await unmount(); +}); + +test("processing-report-navigable-suppresses-resolve-form: a processing report opens into detail, shows enforcement state, and hides the resolve form", async () => { + // Thufir finding 4: processing rows must stay navigable. The enforcement + // state (progress/retry/cancel) lives inside the detail view, so disabling + // the row hides exactly the UI an operator needs while an action is pending. + // "Not actionable" means suppress the resolve form, not block navigation. + // + // Mutation evidence: re-add `disabled={isProcessing}` to the ReportsTab row → + // the click never opens detail, report-detail-fields never renders → red. + // Drop the `isOpen` gate on ResolveReportForm → the resolve form renders for + // a processing report → the resolve-form-absent assertion goes red. + + const origin = "https://admin.example.com"; + const pubkey = "f5".repeat(32); + + const processingItem = { + id: "00000000-0000-0000-0000-000000000010", + communityId: "00000000-0000-0000-0000-000000000002", + communityHost: "relay.example.com", + reportEventId: "aabb", + reporterPubkey: "ccdd", + targetKind: "event", + target: "eeff", + reportType: "spam", + status: "processing", + createdAt: "2024-01-01T00:00:00Z", + }; + const processingDetail = { + ...processingItem, + channelId: null, + note: null, + resolvedBy: null, + resolvedAt: null, + actionId: null, + activeAction: { + id: "00000000-0000-0000-0000-0000000000e1", + requestId: "00000000-0000-0000-0000-0000000000e2", + actorPubkey: + "1111111111111111111111111111111111111111111111111111111111111111", + actorRole: "operator", + action: "ban", + status: "enforcing", + reason: null, + expiresAt: null, + errorMessage: null, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:01Z", + }, + message: null, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([processingItem])); + setIpcHandler("admin_get_report", () => Promise.resolve(processingDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + + // The processing row must be a navigable (non-disabled) button. + const rowButtons = Array.from(container.querySelectorAll("button")).filter( + (btn) => !(btn.getAttribute("data-testid") ?? "").startsWith("admin-tab"), + ); + const processingRow = rowButtons.find((btn) => + btn.textContent?.includes("spam"), + ); + assert.ok(processingRow, "processing report row must be present"); + assert.ok( + !processingRow.disabled, + "processing report row must stay navigable (not disabled)", + ); + + // Navigate into the detail. + await act(async () => { + fireEvent.click(processingRow); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(30); + + // Detail renders (navigation succeeded). + assert.ok( + container.querySelector("[data-testid='report-detail-fields']"), + "report-detail-fields must render after navigating into a processing report", + ); + // Enforcement state block is shown for a processing report with an action. + assert.ok( + container.querySelector("[data-testid='enforcement-state-block']"), + "enforcement-state-block must render for a processing report", + ); + // The resolve form must be suppressed for a non-open (processing) report. + assert.equal( + container.querySelector("[data-testid='resolve-report-form']"), + null, + "resolve form must NOT render for a processing report", + ); + + await unmount(); +}); + +test("feedback-detail-renders-structured-fields: FeedbackDetail shows field layout, not raw JSON", async () => { + // Verifies item 3: the feedback detail view renders data-testid='feedback-detail-fields'. + // Lives here (jsdom) because tab switching and item navigation require fireEvent.click. + // + // Mutation evidence: revert FeedbackFields →
    {JSON.stringify(...)}
    + // → this test goes red ("feedback-detail-fields element must render"). + + const origin = "https://admin.example.com"; + const pubkey = "6".repeat(64); + + // Summary shape returned by GET /admin/feedback (FeedbackSummary wire type). + const feedbackSummary = { + id: "00000000-0000-0000-0000-000000000011", + communityId: "00000000-0000-0000-0000-000000000022", + communityHost: "relay.example.com", + submitterPubkey: "submitter001pubkey", + category: "bug", + bodySummary: "App crashes on startup", + status: "new", + receivedAt: "2024-05-01T09:00:05Z", + }; + + // Full AdminFeedbackDto shape returned by GET /admin/feedback/:id. + const feedbackDetail = { + id: "00000000-0000-0000-0000-000000000011", + communityId: "00000000-0000-0000-0000-000000000022", + communityHost: "relay.example.com", + eventId: "feedevent001", + submitterPubkey: "submitter001pubkey", + category: "bug", + body: "App crashes on startup — full detail body text", + status: "new", + tags: [], + eventCreatedAt: "2024-05-01T09:00:00Z", + receivedAt: "2024-05-01T09:00:05Z", + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => + Promise.resolve([feedbackSummary]), + ); + setIpcHandler("admin_get_feedback", () => Promise.resolve(feedbackDetail)); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + + // Click the Feedback tab. + const feedbackTab = container.querySelector( + "[data-testid='admin-tab-feedback']", + ); + assert.ok(feedbackTab, "Feedback tab must be present"); + await act(async () => { + fireEvent.click(feedbackTab); + await new Promise((r) => setTimeout(r, 30)); + }); + + await settle(30); + + // Pre-navigation: list row shows the summary body text (bodySummary rendered). + // Mutation seam: render `body` instead of `bodySummary` → red because summary + // fixture has no `body` field → row title is blank. + const listText = container.textContent ?? ""; + assert.ok( + listText.includes("App crashes on startup"), + `list row must show bodySummary before navigation; got: ${listText.slice(0, 400)}`, + ); + + // Navigate into the feedback detail — click the first non-tab button. + const allButtons = container.querySelectorAll("button"); + for (const btn of allButtons) { + const testid = btn.getAttribute("data-testid") ?? ""; + if (testid.startsWith("admin-tab")) continue; + await act(async () => { + fireEvent.click(btn); + await new Promise((r) => setTimeout(r, 30)); + }); + break; + } + + await settle(30); + + const fields = container.querySelector( + "[data-testid='feedback-detail-fields']", + ); + assert.ok( + fields !== null, + "feedback-detail-fields element must render — JSON dump not replaced", + ); + + const text = container.textContent ?? ""; + assert.ok( + !text.includes('"body":'), + `raw JSON must not be rendered in feedback detail; got: ${text.slice(0, 400)}`, + ); + + // Real DTO fields must render. + assert.ok( + text.includes("submitter001pubkey"), + `submitterPubkey must render; got: ${text.slice(0, 600)}`, + ); + assert.ok( + text.includes("bug"), + `category must render; got: ${text.slice(0, 600)}`, + ); + assert.ok( + text.includes("App crashes on startup"), + `body must render; got: ${text.slice(0, 600)}`, + ); + + // Fake fields must NOT appear. + assert.ok( + !text.includes("appVersion"), + `invented 'appVersion' field must not render; got: ${text.slice(0, 400)}`, + ); + assert.ok( + !text.includes("authorPubkey"), + `invented 'authorPubkey' field must not render; got: ${text.slice(0, 400)}`, + ); + + // Relative timestamp: formatTimestamp output must match "Xm/h/d ago (...)" shape. + // The fixture receivedAt is far in the past, so it will be "Nd ago (...)". + assert.ok( + /\d+[mhd] ago \(/.test(text) || text.includes("just now ("), + `relative timestamp must render in "Nm/h/d ago (...)" format; got: ${text.slice(0, 600)}`, + ); + + await unmount(); +}); + +// ── contract-dto-nullable-graceful-degradation ──────────────────────────────── + +test("contract-dto-nullable-graceful-degradation: report detail renders em-dash for absent nullable fields", async () => { + // Pins graceful degradation when nullable DTO fields are absent. + // Asserts that fields that are null/absent render as "—" not as empty or crashing. + // + // Mutation evidence: remove the null-guard in DetailRow (change `value != null` + // to `value !== null`) → the em-dash logic breaks for undefined → test goes red. + + const origin = "https://admin.example.com"; + const pubkey = "7".repeat(64); + + const reportItem = { + id: "00000000-0000-0000-0000-000000000077", + communityId: "00000000-0000-0000-0000-000000000002", + communityHost: "relay.example.com", + reportEventId: "aabb", + reporterPubkey: "ccdd", + targetKind: "pubkey", + target: "eeff", + reportType: "nudity", + status: "open", + createdAt: "2024-06-01T12:00:00Z", + }; + + // Detail has no optional fields set and no nested message. + const reportDetail = { + ...reportItem, + channelId: null, + note: null, + resolvedBy: null, + resolvedAt: null, + actionId: null, + message: null, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([reportItem])); + setIpcHandler("admin_get_report", () => Promise.resolve(reportDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + + // Navigate into report detail. + const allButtons = container.querySelectorAll("button"); + for (const btn of allButtons) { + const testid = btn.getAttribute("data-testid") ?? ""; + if (testid.startsWith("admin-tab")) continue; + await act(async () => { + fireEvent.click(btn); + await new Promise((r) => setTimeout(r, 30)); + }); + break; + } + await settle(30); + + const fields = container.querySelector( + "[data-testid='report-detail-fields']", + ); + assert.ok(fields !== null, "report-detail-fields must render"); + + const text = container.textContent ?? ""; + // Em-dash appears for null fields (Note, Channel, Resolved by, etc.). + assert.ok( + text.includes("—"), + `em-dash must appear for null nullable fields; got: ${text.slice(0, 600)}`, + ); + // Nested message block must NOT render when message is null. + assert.ok( + !text.includes("Reported message"), + `nested message block must not render when message is null; got: ${text.slice(0, 600)}`, + ); + + await unmount(); +}); + +// ── contract-dto-mutation-evidence ──────────────────────────────────────────── + +test("contract-dto-mutation-evidence-resolvedBy: wrong key lookup makes resolvedBy invisible", async () => { + // Mutation evidence (a): if ReportFields reads data["resolvedBy"] via a wrong + // key — or if the key in the DTO type is renamed — the resolvedBy value + // disappears from the rendered output. + // + // This test asserts the CORRECT behaviour: resolvedBy IS rendered. + // To produce the red output, rename `resolvedBy` → `resolvedByX` in ReportFields. + + const origin = "https://admin.example.com"; + const pubkey = "8".repeat(64); + + const reportItem = { + id: "00000000-0000-0000-0000-000000000088", + communityId: "00000000-0000-0000-0000-000000000002", + communityHost: "relay.example.com", + reportEventId: "rr01", + reporterPubkey: "pp01", + targetKind: "event", + target: "tt01", + reportType: "harassment", + status: "resolved", + createdAt: "2024-06-01T12:00:00Z", + }; + + const reportDetail = { + ...reportItem, + channelId: null, + note: "case closed", + resolvedBy: "moderator_pubkey_hex", + resolvedAt: "2024-06-02T08:00:00Z", + actionId: null, + message: null, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([reportItem])); + setIpcHandler("admin_get_report", () => Promise.resolve(reportDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + + const allButtons = container.querySelectorAll("button"); + for (const btn of allButtons) { + const testid = btn.getAttribute("data-testid") ?? ""; + if (testid.startsWith("admin-tab")) continue; + await act(async () => { + fireEvent.click(btn); + await new Promise((r) => setTimeout(r, 30)); + }); + break; + } + await settle(30); + + const text = container.textContent ?? ""; + + // The resolvedBy pubkey must appear. + // Seam: asserting `data.resolvedBy` reaches the rendered DetailRow value. + // Mutation: rename `resolvedBy` → `resolvedByX` in ReportFields → "moderator_pubkey_hex" absent → red. + assert.ok( + text.includes("moderator_pubkey_hex"), + `resolvedBy value must render via data.resolvedBy; got: ${text.slice(0, 600)}`, + ); + + // The note must also render. + assert.ok( + text.includes("case closed"), + `note value must render via data.note; got: ${text.slice(0, 600)}`, + ); + + await unmount(); +}); + +test("contract-dto-mutation-evidence-nested-message: removing message block hides content", async () => { + // Mutation evidence (b): removing the nested message block from ReportFields + // makes the reported message content invisible. + // + // This test asserts the CORRECT behaviour: the nested message IS rendered, + // and the (deleted) indicator appears when deletedAt is non-null. + // To produce the red output, remove the `{data.message != null && ...}` block. + + const origin = "https://admin.example.com"; + const pubkey = "9".repeat(64); + + const reportItem = { + id: "00000000-0000-0000-0000-000000000099", + communityId: "00000000-0000-0000-0000-000000000002", + communityHost: "relay.example.com", + reportEventId: "rr02", + reporterPubkey: "pp02", + targetKind: "event", + target: "tt02", + reportType: "spam", + status: "open", + createdAt: "2024-06-01T12:00:00Z", + }; + + const reportDetail = { + ...reportItem, + channelId: null, + note: null, + resolvedBy: null, + resolvedAt: null, + actionId: null, + message: { + authorPubkey: "msg_author_pubkey", + content: "buy cheap meds at spamsite.example", + createdAt: "2024-06-01T11:55:00Z", + // Non-null deletedAt — exercises the deleted indicator branch. + deletedAt: "2024-06-01T12:10:00Z", + }, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([reportItem])); + setIpcHandler("admin_get_report", () => Promise.resolve(reportDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + + const allButtons = container.querySelectorAll("button"); + for (const btn of allButtons) { + const testid = btn.getAttribute("data-testid") ?? ""; + if (testid.startsWith("admin-tab")) continue; + await act(async () => { + fireEvent.click(btn); + await new Promise((r) => setTimeout(r, 30)); + }); + break; + } + await settle(30); + + const text = container.textContent ?? ""; + + // Seam: asserting the nested message block renders its content field. + // Mutation: remove `{data.message != null && ...}` → message content absent → red. + assert.ok( + text.includes("buy cheap meds at spamsite.example"), + `nested message content must render; got: ${text.slice(0, 600)}`, + ); + assert.ok( + text.includes("msg_author_pubkey"), + `nested message authorPubkey must render; got: ${text.slice(0, 600)}`, + ); + assert.ok( + text.includes("Reported message"), + `"Reported message" heading must render; got: ${text.slice(0, 600)}`, + ); + // Seam: asserting the deleted indicator renders when deletedAt is non-null. + // Mutation: remove the `{data.message.deletedAt != null && ...}` span → "(deleted)" absent → red. + assert.ok( + text.includes("(deleted)"), + `deleted indicator must render when deletedAt is non-null; got: ${text.slice(0, 600)}`, + ); + + await unmount(); +}); + +// ── NIP-11 auto-discovery ───────────────────────────────────────────────── + +test("discovery-success: no saved origin auto-discovers and probes without manual input", async () => { + // Verifies the mount effect: when get_admin_origin returns null, the card + // calls admin_discover_origin, and on a discovered origin it seeds the input + // and auto-probes — the operator types nothing. + // + // Fails if the discovery branch is removed from the mount effect: no + // admin_discover_origin call, empty input, panel never renders. + + const pubkey = "1".repeat(64); + const discovered = "http://127.0.0.1:3000"; + + setIpcHandler("get_admin_origin", () => Promise.resolve(null)); + let discoverCalls = 0; + setIpcHandler("admin_discover_origin", () => { + discoverCalls += 1; + return Promise.resolve(discovered); + }); + const probeOrigins = []; + setIpcHandler("admin_probe", (args) => { + probeOrigins.push(args?.origin ?? "(none)"); + return Promise.resolve({ state: "nip98Authorized" }); + }); + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const qc = makeQueryClient(pubkey); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(30); + + assert.equal( + discoverCalls, + 1, + "admin_discover_origin must be called once when no origin is saved", + ); + assert.deepEqual( + probeOrigins, + [discovered], + `discovered origin must be auto-probed; got: ${JSON.stringify(probeOrigins)}`, + ); + + const input = container.querySelector("[data-testid='admin-origin-input']"); + assert.equal( + input?.value, + discovered, + `input must be pre-filled with the discovered origin; got: "${input?.value}"`, + ); + + const panel = container.querySelector("[data-testid='admin-console-panel']"); + assert.ok( + panel, + "admin-console-panel must render after auto-discovered origin authorizes", + ); + + await unmount(); +}); + +test("discovery-absent: no saved origin and no advertised admin_api falls back to manual entry", async () => { + // Verifies the fallback path: get_admin_origin null + admin_discover_origin + // null → empty input, no probe fires, no panel — the operator can type a URL. + // + // Fails if discovery null is not treated as "fall back": a probe would fire + // for a null/empty origin or the panel would render. + + const pubkey = "2".repeat(64); + + setIpcHandler("get_admin_origin", () => Promise.resolve(null)); + let discoverCalls = 0; + setIpcHandler("admin_discover_origin", () => { + discoverCalls += 1; + return Promise.resolve(null); + }); + const probeOrigins = []; + setIpcHandler("admin_probe", (args) => { + probeOrigins.push(args?.origin ?? "(none)"); + return Promise.resolve({ state: "disabled" }); + }); + + const qc = makeQueryClient(pubkey); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(30); + + assert.equal(discoverCalls, 1, "admin_discover_origin must be attempted"); + assert.deepEqual( + probeOrigins, + [], + `no probe must fire when discovery returns null; got: ${JSON.stringify(probeOrigins)}`, + ); + + const input = container.querySelector("[data-testid='admin-origin-input']"); + assert.equal( + input?.value, + "", + `input must be empty for manual entry when discovery finds nothing; got: "${input?.value}"`, + ); + const panel = container.querySelector("[data-testid='admin-console-panel']"); + assert.equal( + panel, + null, + "admin-console-panel must not render when there is no discovered origin", + ); + + await unmount(); +}); + +test("discovery-error: a failed discovery fetch falls back to manual entry without surfacing an error", async () => { + // The relay-side admin_api validation lives in Rust: an advertised-but-invalid + // value resolves to null there. A transport error rejects the promise; the + // card swallows it and falls back to manual entry rather than showing an + // error badge (discovery is best-effort, not operator action). + // + // Fails if the discovery try/catch is removed: the rejection propagates to + // the outer catch and the card renders an error badge instead of a clean + // manual-entry state. + + const pubkey = "3".repeat(64); + + setIpcHandler("get_admin_origin", () => Promise.resolve(null)); + setIpcHandler("admin_discover_origin", () => + Promise.reject(new Error("relay unreachable: network error")), + ); + const probeOrigins = []; + setIpcHandler("admin_probe", (args) => { + probeOrigins.push(args?.origin ?? "(none)"); + return Promise.resolve({ state: "disabled" }); + }); + + const qc = makeQueryClient(pubkey); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(30); + + assert.deepEqual( + probeOrigins, + [], + `no probe must fire when discovery errors; got: ${JSON.stringify(probeOrigins)}`, + ); + const input = container.querySelector("[data-testid='admin-origin-input']"); + assert.equal( + input?.value, + "", + `input must be empty for manual entry after a discovery error; got: "${input?.value}"`, + ); + const text = container.textContent ?? ""; + assert.ok( + !text.includes("network error"), + `a best-effort discovery error must not surface as an error badge; got: ${text.slice(0, 200)}`, + ); + + await unmount(); +}); + +test("discovery-skipped: a saved origin takes precedence and discovery is not attempted", async () => { + // Verifies the manual-fallback-wins invariant: an explicitly saved origin + // is probed directly and admin_discover_origin is never called. + // + // Fails if discovery runs unconditionally and clobbers the saved origin. + + const pubkey = "4".repeat(64); + const saved = "https://admin.example.com"; + + setIpcHandler("get_admin_origin", () => Promise.resolve(saved)); + let discoverCalls = 0; + setIpcHandler("admin_discover_origin", () => { + discoverCalls += 1; + return Promise.resolve("http://127.0.0.1:3000"); + }); + const probeOrigins = []; + setIpcHandler("admin_probe", (args) => { + probeOrigins.push(args?.origin ?? "(none)"); + return Promise.resolve({ state: "disabled" }); + }); + + const qc = makeQueryClient(pubkey); + const { container, doRender, unmount } = mountCard(qc); + await doRender(); + await settle(30); + + assert.equal( + discoverCalls, + 0, + "admin_discover_origin must NOT be called when an origin is already saved", + ); + assert.deepEqual( + probeOrigins, + [saved], + `the saved origin must be probed, not a discovered one; got: ${JSON.stringify(probeOrigins)}`, + ); + const input = container.querySelector("[data-testid='admin-origin-input']"); + assert.equal( + input?.value, + saved, + `input must show the saved origin; got: "${input?.value}"`, + ); + + await unmount(); +}); + +// ── community grouping ──────────────────────────────────────────────────── + +test("reports-grouped-by-community: multi-community reports render per-community headings", async () => { + // The admin API returns deployment-wide reports; the console buckets them + // by community for triage. Two communities → two group headings; rows stay + // navigable (the first non-tab, non-processing report opens its detail). + // + // Mutation evidence: revert ReportsTab to a flat
      → community-group + // headings vanish and this test goes red. + + const origin = "https://admin.example.com"; + const pubkey = "a7".repeat(32); + + const reports = [ + { + id: "00000000-0000-0000-0000-0000000000a1", + communityId: "comm-1", + communityHost: "alpha.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + status: "open", + createdAt: "2024-06-01T12:00:00Z", + }, + { + id: "00000000-0000-0000-0000-0000000000a2", + communityId: "comm-2", + communityHost: "beta.example.com", + reportEventId: "dd", + reporterPubkey: "ee", + targetKind: "event", + target: "ff", + reportType: "abuse", + status: "open", + createdAt: "2024-06-02T12:00:00Z", + }, + ]; + + setIpcHandler("admin_list_reports", () => Promise.resolve(reports)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + + const groups = container.querySelectorAll("[data-testid='community-group']"); + assert.equal( + groups.length, + 2, + `two communities must render two groups; got ${groups.length}`, + ); + + const hosts = Array.from( + container.querySelectorAll("[data-testid='community-group-host']"), + ).map((el) => el.textContent); + assert.deepEqual( + hosts, + ["alpha.example.com", "beta.example.com"], + `group headings must show each community host in first-seen order; got: ${JSON.stringify(hosts)}`, + ); + + await unmount(); +}); + +test("feedback-grouped-by-community: multi-community feedback renders per-community headings", async () => { + // Same grouping contract for the Feedback tab. + // + // Mutation evidence: revert FeedbackTab to a flat
        → group headings + // vanish and this test goes red. + + const origin = "https://admin.example.com"; + const pubkey = "b8".repeat(32); + + const feedback = [ + { + id: "00000000-0000-0000-0000-0000000000b1", + communityId: "comm-1", + communityHost: "alpha.example.com", + submitterPubkey: "sub1", + category: "bug", + bodySummary: "Alpha feedback body", + status: "new", + receivedAt: "2024-06-01T09:00:00Z", + }, + { + id: "00000000-0000-0000-0000-0000000000b2", + communityId: "comm-2", + communityHost: "beta.example.com", + submitterPubkey: "sub2", + category: "idea", + bodySummary: "Beta feedback body", + status: "new", + receivedAt: "2024-06-02T09:00:00Z", + }, + ]; + + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => Promise.resolve(feedback)); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + + // Switch to the Feedback tab. + const feedbackTab = container.querySelector( + "[data-testid='admin-tab-feedback']", + ); + assert.ok(feedbackTab, "Feedback tab must be present"); + await act(async () => { + fireEvent.click(feedbackTab); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(30); + + const hosts = Array.from( + container.querySelectorAll("[data-testid='community-group-host']"), + ).map((el) => el.textContent); + assert.deepEqual( + hosts, + ["alpha.example.com", "beta.example.com"], + `feedback group headings must show each community host; got: ${JSON.stringify(hosts)}`, + ); + + await unmount(); +}); + +test("feedback-status-honest: a reviewed detail reports reviewed, never defaulting to new", async () => { + // Thufir finding 5 (desktop half): `status` is a required wire field. A + // reviewed/archived entry must render its real status after reload, not be + // silently presented as "new". The status control must also initialize its + // selected state from the server value. + // + // Mutation evidence: reinstate `detailState.data.status ?? "new"` in + // FeedbackDetail → a reviewed entry would still show, but re-adding the + // absent-defaulting cast and feeding an entry with no status would present + // it as new; here we assert the reviewed value round-trips and its button + // is the active (default-variant) one. + + const origin = "https://admin.example.com"; + const pubkey = "d5".repeat(32); + + const reviewedSummary = { + id: "00000000-0000-0000-0000-0000000000d5", + communityId: "comm-1", + communityHost: "alpha.example.com", + submitterPubkey: "sub-reviewed", + category: "bug", + bodySummary: "Already-triaged feedback", + status: "reviewed", + receivedAt: "2024-06-01T09:00:00Z", + }; + const reviewedDetail = { + id: reviewedSummary.id, + communityId: reviewedSummary.communityId, + communityHost: reviewedSummary.communityHost, + eventId: "revevent", + submitterPubkey: reviewedSummary.submitterPubkey, + category: "bug", + body: "Already-triaged feedback full body", + status: "reviewed", + tags: [], + eventCreatedAt: "2024-06-01T09:00:00Z", + receivedAt: "2024-06-01T09:00:00Z", + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => + Promise.resolve([reviewedSummary]), + ); + setIpcHandler("admin_get_feedback", () => Promise.resolve(reviewedDetail)); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + + // Switch to the Feedback tab. + const feedbackTab = container.querySelector( + "[data-testid='admin-tab-feedback']", + ); + assert.ok(feedbackTab, "Feedback tab must be present"); + await act(async () => { + fireEvent.click(feedbackTab); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(30); + + // List row shows the "reviewed" badge (status !== "new"). + const listText = container.textContent ?? ""; + assert.ok( + listText.includes("reviewed"), + `list row must show the reviewed badge; got: ${listText.slice(0, 400)}`, + ); + + // Navigate into the feedback detail. + const listRow = Array.from(container.querySelectorAll("button")).find( + (btn) => + !(btn.getAttribute("data-testid") ?? "").startsWith("admin-tab") && + btn.textContent?.includes("Already-triaged feedback"), + ); + assert.ok(listRow, "feedback list row must be present"); + await act(async () => { + fireEvent.click(listRow); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(30); + + // The status control initializes from the server value: the "reviewed" + // button is the active (default-variant) selection, not "new". + const control = container.querySelector( + "[data-testid='feedback-status-control']", + ); + assert.ok(control, "feedback status control must render"); + const reviewedBtn = container.querySelector( + "[data-testid='feedback-status-btn-reviewed']", + ); + const newBtn = container.querySelector( + "[data-testid='feedback-status-btn-new']", + ); + assert.ok(reviewedBtn && newBtn, "status buttons must render"); + // The active status is styled with a ring highlight (see FeedbackStatusControl). + assert.ok( + (reviewedBtn.className ?? "").includes("ring-2"), + `the reviewed button must be marked active; got className: ${reviewedBtn.className}`, + ); + assert.ok( + !(newBtn.className ?? "").includes("ring-2"), + `the new button must NOT be active for a reviewed entry; got className: ${newBtn.className}`, + ); + + await unmount(); +}); + +// ── reopen ──────────────────────────────────────────────────────────────── + +/** + * Mount the panel, wait for the list, then click the first non-tab report row + * to open its detail. Returns after the detail has settled. + */ +async function openFirstReportDetail(container) { + const allButtons = container.querySelectorAll("button"); + for (const btn of allButtons) { + const testid = btn.getAttribute("data-testid") ?? ""; + if (testid.startsWith("admin-tab")) continue; + await act(async () => { + fireEvent.click(btn); + await new Promise((r) => setTimeout(r, 30)); + }); + return; + } + throw new Error("no navigable report row found"); +} + +test("reopen-form-gated-by-status: resolved report shows the reopen form, open report does not", async () => { + // The reopen form must render only for terminal reports + // (resolved | dismissed | escalated) and never for an open report — an open + // report shows the resolve form instead. + // + // Mutation evidence: drop the `isReopenable` gate → the form renders for + // open reports too and the second assertion goes red. + + const origin = "https://admin.example.com"; + const pubkey = "c1".repeat(32); + + const resolvedItem = { + id: "00000000-0000-0000-0000-0000000000c1", + communityId: "comm-1", + communityHost: "alpha.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + status: "resolved", + createdAt: "2024-06-01T12:00:00Z", + }; + const resolvedDetail = { + ...resolvedItem, + channelId: null, + note: null, + resolvedBy: "mod_pubkey", + resolvedAt: "2024-06-02T08:00:00Z", + actionId: null, + message: null, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([resolvedItem])); + setIpcHandler("admin_get_report", () => Promise.resolve(resolvedDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + assert.ok( + container.querySelector("[data-testid='reopen-report-form']"), + "reopen form must render for a resolved report", + ); + assert.equal( + container.querySelector("[data-testid='resolve-report-form']"), + null, + "resolve form must NOT render for a resolved report", + ); + + await unmount(); +}); + +test("reopen-submit: calls admin_reopen_report with requestId+reason, toasts, and refreshes", async () => { + // The reopen submit must POST {requestId, reason} to admin_reopen_report, + // fire a success toast, and bump the resolve generation so the detail + // reloads (verified here by a second admin_get_report call returning the + // now-open report, which flips the UI to the resolve form). + // + // Mutation evidence: remove `onReopened()` → no reload, detail stays + // resolved, and the resolve-form assertion goes red. Remove the toast → + // capturedToasts assertion goes red. + + const origin = "https://admin.example.com"; + const pubkey = "c2".repeat(32); + + const base = { + id: "00000000-0000-0000-0000-0000000000c2", + communityId: "comm-1", + communityHost: "alpha.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + createdAt: "2024-06-01T12:00:00Z", + }; + const dismissedItem = { ...base, status: "dismissed" }; + const dismissedDetail = { + ...dismissedItem, + channelId: null, + note: null, + resolvedBy: "mod_pubkey", + resolvedAt: "2024-06-02T08:00:00Z", + actionId: null, + message: null, + }; + const openDetail = { + ...base, + status: "open", + channelId: null, + note: null, + resolvedBy: null, + resolvedAt: null, + actionId: null, + message: null, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([dismissedItem])); + // First detail load: dismissed. After reopen, the generation bump reloads + // and the report is now open. + let detailCalls = 0; + setIpcHandler("admin_get_report", () => { + detailCalls += 1; + return Promise.resolve(detailCalls === 1 ? dismissedDetail : openDetail); + }); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + let reopenArgs = null; + setIpcHandler("admin_reopen_report", (args) => { + reopenArgs = args; + return Promise.resolve({ status: "open" }); + }); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + // Type a reason. + const reasonInput = container.querySelector( + "[data-testid='reopen-reason-input']", + ); + assert.ok(reasonInput, "reopen reason input must be present"); + await act(async () => { + fireEvent.change(reasonInput, { target: { value: "new evidence" } }); + await new Promise((r) => setTimeout(r, 5)); + }); + + // Submit. + const submit = container.querySelector("[data-testid='reopen-submit-btn']"); + assert.ok(submit, "reopen submit button must be present"); + await act(async () => { + fireEvent.click(submit); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(20); + + assert.ok(reopenArgs, "admin_reopen_report must be invoked"); + assert.equal(reopenArgs.origin, origin, "origin must be forwarded"); + assert.equal(reopenArgs.id, base.id, "report id must be forwarded"); + assert.equal( + reopenArgs.body?.reason, + "new evidence", + "reason must be forwarded in the body", + ); + assert.ok( + typeof reopenArgs.body?.requestId === "string" && + reopenArgs.body.requestId.length > 0, + `requestId must be a non-empty string; got: ${JSON.stringify(reopenArgs.body?.requestId)}`, + ); + + assert.ok( + capturedToasts.some((m) => m.toLowerCase().includes("reopen")), + `a reopen success toast must fire; got: ${JSON.stringify(capturedToasts)}`, + ); + + // Refresh: detail reloaded (call 2) and the report is now open → resolve form. + assert.ok( + detailCalls >= 2, + "detail must reload after reopen (generation bump)", + ); + assert.ok( + container.querySelector("[data-testid='resolve-report-form']"), + "after reopen, the now-open report must show the resolve form", + ); + + await unmount(); +}); + +test("reopen-enforced-copy: a report with an actionId warns enforcement is not reversed", async () => { + // Reopen is re-triage only. When the report carries an actionId (enforcement + // was applied), the copy must say the enforcement is not reversed. + // + // Mutation evidence: collapse the `wasEnforced` branch to the generic copy → + // the "not reversed" wording for un-ban/un-timeout/restore disappears. + + const origin = "https://admin.example.com"; + const pubkey = "c3".repeat(32); + + const escalatedItem = { + id: "00000000-0000-0000-0000-0000000000c3", + communityId: "comm-1", + communityHost: "alpha.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "pubkey", + target: "cc", + reportType: "abuse", + status: "escalated", + createdAt: "2024-06-01T12:00:00Z", + }; + const escalatedDetail = { + ...escalatedItem, + channelId: null, + note: null, + resolvedBy: "mod_pubkey", + resolvedAt: "2024-06-02T08:00:00Z", + actionId: "00000000-0000-0000-0000-0000000000ff", + message: null, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([escalatedItem])); + setIpcHandler("admin_get_report", () => Promise.resolve(escalatedDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + const form = container.querySelector("[data-testid='reopen-report-form']"); + assert.ok(form, "reopen form must render for an escalated report"); + const text = form.textContent ?? ""; + assert.ok( + text.toLowerCase().includes("not reversed"), + `enforced-report copy must state the action is not reversed; got: ${text}`, + ); + + await unmount(); +}); + +test("reopen-409-preserves-requestId: a not-reopenable conflict reuses the same requestId on retry", async () => { + // A 409 (report is not reopenable — e.g. it moved to processing) is an + // idempotency-relevant failure: the relay has a claim, so the same requestId + // must be reused on retry to let the relay dedupe. The native command carries + // the relay's HTTP status on the rejected error (`relayStatus: 409`), and the + // UI's preserveRequestIdOnError reads it — no string-matching. + // + // Mutation evidence: make preserveRequestIdOnError reset on 409 → the two + // attempts carry different ids and this goes red. + + const origin = "https://admin.example.com"; + const pubkey = "c4".repeat(32); + + const resolvedItem = { + id: "00000000-0000-0000-0000-0000000000c4", + communityId: "comm-1", + communityHost: "alpha.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + status: "resolved", + createdAt: "2024-06-01T12:00:00Z", + }; + const resolvedDetail = { + ...resolvedItem, + channelId: null, + note: null, + resolvedBy: "mod_pubkey", + resolvedAt: "2024-06-02T08:00:00Z", + actionId: null, + message: null, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([resolvedItem])); + setIpcHandler("admin_get_report", () => Promise.resolve(resolvedDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const requestIds = []; + setIpcHandler("admin_reopen_report", (args) => { + requestIds.push(args?.body?.requestId); + return mutationReject( + "admin API error: 409 report is not reopenable (current status: processing)", + 409, + ); + }); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + const submit = container.querySelector("[data-testid='reopen-submit-btn']"); + assert.ok(submit, "reopen submit button must be present"); + + // First attempt → 409. + await act(async () => { + fireEvent.click(submit); + await new Promise((r) => setTimeout(r, 20)); + }); + // Second attempt → 409 again; requestId must be identical. + await act(async () => { + fireEvent.click(submit); + await new Promise((r) => setTimeout(r, 20)); + }); + + assert.equal(requestIds.length, 2, "two reopen attempts must have been made"); + assert.equal( + requestIds[0], + requestIds[1], + `requestId must be preserved across a 409 retry; got: ${JSON.stringify(requestIds)}`, + ); + + // No success toast on a 409. + assert.ok( + !capturedToasts.some((m) => m.toLowerCase().includes("reopen")), + `no success toast on a 409; got: ${JSON.stringify(capturedToasts)}`, + ); + // The error is surfaced via toast.error with the parsed relay message. + assert.ok( + capturedErrorToasts.some((m) => m.includes("not reopenable")), + `the 409 error message must surface via toast.error; got: ${JSON.stringify(capturedErrorToasts)}`, + ); + + await unmount(); +}); + +test("reopen-lost-response-preserves-requestId: an ambiguous transport failure reuses the requestId on retry", async () => { + // The bug this fixes: the native layer serializes a timeout/disconnect as + // `relay unreachable: …` and a lost response body as `admin response stream + // error` — neither contains "409"/"processing", so the old string-match + // cleared the requestId and the retry became a brand-new command. The + // concrete harm is a two-operator interleave: A's reopen COMMITS, the + // response is lost; B resolves the now-open report; A's retry with a fresh id + // reopens B's later resolution. Reusing the original id makes the retry hit + // the relay's idempotent path harmlessly. + // + // A lost-response failure carries no relay verdict (`relayStatus: null`), so + // preserveRequestIdOnError must keep the id. Mutation evidence: change the + // null-status branch to reset → the two attempts carry different ids, red. + + const origin = "https://admin.example.com"; + const pubkey = "c5".repeat(32); + + const resolvedItem = { + id: "00000000-0000-0000-0000-0000000000c5", + communityId: "comm-1", + communityHost: "alpha.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + status: "resolved", + createdAt: "2024-06-01T12:00:00Z", + }; + const resolvedDetail = { + ...resolvedItem, + channelId: null, + note: null, + resolvedBy: "mod_pubkey", + resolvedAt: "2024-06-02T08:00:00Z", + actionId: null, + message: null, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([resolvedItem])); + setIpcHandler("admin_get_report", () => Promise.resolve(resolvedDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const requestIds = []; + setIpcHandler("admin_reopen_report", (args) => { + requestIds.push(args?.body?.requestId); + // Transport failure: no relay answer, so no HTTP status. + return mutationReject("relay unreachable: network error", null); + }); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + const submit = container.querySelector("[data-testid='reopen-submit-btn']"); + assert.ok(submit, "reopen submit button must be present"); + + // First attempt → lost response. + await act(async () => { + fireEvent.click(submit); + await new Promise((r) => setTimeout(r, 20)); + }); + // Second attempt → same ambiguous failure; requestId must be identical. + await act(async () => { + fireEvent.click(submit); + await new Promise((r) => setTimeout(r, 20)); + }); + + assert.equal(requestIds.length, 2, "two reopen attempts must have been made"); + assert.equal( + requestIds[0], + requestIds[1], + `requestId must be preserved across a lost-response retry; got: ${JSON.stringify(requestIds)}`, + ); + + await unmount(); +}); + +test("reopen-4xx-resets-requestId: a definitive pre-commit rejection uses a fresh requestId", async () => { + // A non-409 4xx (e.g. 400 bad request) is a definitive pre-commit rejection: + // the relay refused the input and committed nothing, so a corrected + // resubmission is a genuinely new command and a fresh requestId is correct. + // This is the ONLY case that resets — the counterpart to the ambiguous + // failures above. + // + // Mutation evidence: make preserveRequestIdOnError preserve on a 400 → the + // two attempts share an id and this goes red. + + const origin = "https://admin.example.com"; + const pubkey = "c6".repeat(32); + + const resolvedItem = { + id: "00000000-0000-0000-0000-0000000000c6", + communityId: "comm-1", + communityHost: "alpha.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + status: "resolved", + createdAt: "2024-06-01T12:00:00Z", + }; + const resolvedDetail = { + ...resolvedItem, + channelId: null, + note: null, + resolvedBy: "mod_pubkey", + resolvedAt: "2024-06-02T08:00:00Z", + actionId: null, + message: null, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([resolvedItem])); + setIpcHandler("admin_get_report", () => Promise.resolve(resolvedDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const requestIds = []; + setIpcHandler("admin_reopen_report", (args) => { + requestIds.push(args?.body?.requestId); + return mutationReject("admin API error: bad request", 400); + }); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + const submit = container.querySelector("[data-testid='reopen-submit-btn']"); + assert.ok(submit, "reopen submit button must be present"); + + await act(async () => { + fireEvent.click(submit); + await new Promise((r) => setTimeout(r, 20)); + }); + await act(async () => { + fireEvent.click(submit); + await new Promise((r) => setTimeout(r, 20)); + }); + + assert.equal(requestIds.length, 2, "two reopen attempts must have been made"); + assert.notEqual( + requestIds[0], + requestIds[1], + `a non-409 4xx must reset the requestId; got: ${JSON.stringify(requestIds)}`, + ); + + await unmount(); +}); + +test("resolve-lost-response-preserves-requestId: an ambiguous transport failure reuses the requestId on retry", async () => { + // The resolve path is the enforcement seam and carries the same stale-intent + // risk as reopen: a lost-response failure (`relayStatus: null`, no relay + // verdict) must reuse the idempotency requestId so a retry dedupes against a + // commit that may have landed — otherwise a retry with a fresh id re-applies + // an enforcement action over another operator's intervening state. + // + // Mutation evidence: replace the resolve catch's preservation branch with an + // unconditional `requestIdRef.current = null` → the two attempts carry + // different ids and this goes red (the helper and reopen path stay intact). + + const origin = "https://admin.example.com"; + const pubkey = "c7".repeat(32); + + const openItem = { + id: "00000000-0000-0000-0000-0000000000c7", + communityId: "comm-1", + communityHost: "alpha.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + status: "open", + createdAt: "2024-06-01T12:00:00Z", + }; + const openDetail = { + ...openItem, + channelId: null, + note: null, + resolvedBy: null, + resolvedAt: null, + actionId: null, + message: null, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([openItem])); + setIpcHandler("admin_get_report", () => Promise.resolve(openDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const requestIds = []; + setIpcHandler("admin_resolve_report", (args) => { + requestIds.push(args?.body?.requestId); + // Transport failure: no relay answer, so no HTTP status. + return mutationReject("relay unreachable: network error", null); + }); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + // Select the dismiss action so the resolve submit button appears. + const dismissBtn = container.querySelector( + "[data-testid='action-btn-dismiss']", + ); + assert.ok(dismissBtn, "dismiss action must be present"); + await act(async () => { + fireEvent.click(dismissBtn); + await new Promise((r) => setTimeout(r, 10)); + }); + const submit = container.querySelector("[data-testid='resolve-submit-btn']"); + assert.ok( + submit, + "resolve submit button must appear after selecting dismiss", + ); + + // First attempt → lost response. + await act(async () => { + fireEvent.click(submit); + await new Promise((r) => setTimeout(r, 20)); + }); + // Second attempt → same ambiguous failure; requestId must be identical. + await act(async () => { + fireEvent.click(submit); + await new Promise((r) => setTimeout(r, 20)); + }); + + assert.equal( + requestIds.length, + 2, + "two resolve attempts must have been made", + ); + assert.equal( + requestIds[0], + requestIds[1], + `requestId must be preserved across a resolve lost-response retry; got: ${JSON.stringify(requestIds)}`, + ); + + await unmount(); +}); + +test("resolve-4xx-resets-requestId: a definitive pre-commit rejection uses a fresh requestId", async () => { + // The resolve counterpart to reopen-4xx-resets: a non-409 4xx whose full body + // was read is a definitive pre-commit rejection, so a corrected resubmission + // is a genuinely new command and a fresh requestId is correct. Pins the + // resolve call-site's reset branch specifically. + // + // Mutation evidence: make the resolve catch preserve unconditionally → the + // two attempts share an id and this goes red. + + const origin = "https://admin.example.com"; + const pubkey = "c8".repeat(32); + + const openItem = { + id: "00000000-0000-0000-0000-0000000000c8", + communityId: "comm-1", + communityHost: "alpha.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + status: "open", + createdAt: "2024-06-01T12:00:00Z", + }; + const openDetail = { + ...openItem, + channelId: null, + note: null, + resolvedBy: null, + resolvedAt: null, + actionId: null, + message: null, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([openItem])); + setIpcHandler("admin_get_report", () => Promise.resolve(openDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const requestIds = []; + setIpcHandler("admin_resolve_report", (args) => { + requestIds.push(args?.body?.requestId); + // Full body read → authoritative pre-commit rejection. + return mutationReject("admin API error: bad request", 400); + }); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + const dismissBtn = container.querySelector( + "[data-testid='action-btn-dismiss']", + ); + assert.ok(dismissBtn, "dismiss action must be present"); + await act(async () => { + fireEvent.click(dismissBtn); + await new Promise((r) => setTimeout(r, 10)); + }); + const submit = container.querySelector("[data-testid='resolve-submit-btn']"); + assert.ok( + submit, + "resolve submit button must appear after selecting dismiss", + ); + + await act(async () => { + fireEvent.click(submit); + await new Promise((r) => setTimeout(r, 20)); + }); + await act(async () => { + fireEvent.click(submit); + await new Promise((r) => setTimeout(r, 20)); + }); + + assert.equal( + requestIds.length, + 2, + "two resolve attempts must have been made", + ); + assert.notEqual( + requestIds[0], + requestIds[1], + `a definitive non-409 4xx must reset the resolve requestId; got: ${JSON.stringify(requestIds)}`, + ); + + await unmount(); +}); + +test("reopen-truncated-4xx-preserves-requestId: a 4xx with a lost body reuses the requestId on retry", async () => { + // Status alone is not a verdict: a 4xx whose body was lost mid-stream + // (`bodyComplete: false`) is NOT a definitive pre-commit rejection — the + // relay answered with a status but the outcome is unknown, so the requestId + // must be preserved and the retry left to dedupe. Only a 4xx with a fully + // read body resets. This pins the `bodyComplete` discriminator: reset-on- + // status-alone would clear the key here and re-issue a fresh command. + // + // Mutation evidence: drop the `bodyComplete` gate (reset every non-409 4xx) → + // the two attempts carry different ids and this goes red. + + const origin = "https://admin.example.com"; + const pubkey = "c9".repeat(32); + + const resolvedItem = { + id: "00000000-0000-0000-0000-0000000000c9", + communityId: "comm-1", + communityHost: "alpha.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + status: "resolved", + createdAt: "2024-06-01T12:00:00Z", + }; + const resolvedDetail = { + ...resolvedItem, + channelId: null, + note: null, + resolvedBy: "mod_pubkey", + resolvedAt: "2024-06-02T08:00:00Z", + actionId: null, + message: null, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([resolvedItem])); + setIpcHandler("admin_get_report", () => Promise.resolve(resolvedDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const requestIds = []; + setIpcHandler("admin_reopen_report", (args) => { + requestIds.push(args?.body?.requestId); + // Status arrived but the body was lost mid-stream: outcome unknown. + return mutationReject( + "admin response stream error: connection reset", + 400, + false, + ); + }); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + const submit = container.querySelector("[data-testid='reopen-submit-btn']"); + assert.ok(submit, "reopen submit button must be present"); + + await act(async () => { + fireEvent.click(submit); + await new Promise((r) => setTimeout(r, 20)); + }); + await act(async () => { + fireEvent.click(submit); + await new Promise((r) => setTimeout(r, 20)); + }); + + assert.equal(requestIds.length, 2, "two reopen attempts must have been made"); + assert.equal( + requestIds[0], + requestIds[1], + `a truncated 4xx (bodyComplete false) must preserve the requestId; got: ${JSON.stringify(requestIds)}`, + ); + + await unmount(); +}); + +test("cancel-on-failed: a failed action offers Cancel, POSTs {actionId} to admin_cancel_report, and reloads to open", async () => { + // Cancel-then-resolve is the only recovery from a failed enforcement. The + // block offers Cancel on `status: "failed"`, fences it on the action id, and + // on success the report returns to `open` — the detail reload then serves + // activeAction: null and re-exposes the resolve form for a fresh attempt. + // + // Mutation evidence: revert handleCancel to the old resolve-with-dismiss + // masquerade → admin_cancel_report is never called and cancelArgs stays null. + // Restore the `!activeAction` gate on the resolve form → the reopened report + // still carries no action here, so this test isolates the cancel wiring. + + const origin = "https://admin.example.com"; + const pubkey = "e5".repeat(32); + + const base = { + id: "00000000-0000-0000-0000-0000000000e5", + communityId: "00000000-0000-0000-0000-000000000002", + communityHost: "relay.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + createdAt: "2024-06-01T12:00:00Z", + }; + const actionId = "00000000-0000-0000-0000-0000000000f1"; + const failedDetail = { + ...base, + status: "processing", + channelId: null, + note: null, + resolvedBy: null, + resolvedAt: null, + actionId: null, + activeAction: { + id: actionId, + requestId: "00000000-0000-0000-0000-0000000000f2", + actorPubkey: + "1111111111111111111111111111111111111111111111111111111111111111", + actorRole: "operator", + action: "ban", + status: "failed", + reason: null, + expiresAt: null, + errorMessage: "adapter timeout", + createdAt: "2024-06-01T12:00:00Z", + updatedAt: "2024-06-01T12:00:05Z", + }, + message: null, + }; + const openDetail = { + ...base, + status: "open", + channelId: null, + note: null, + resolvedBy: null, + resolvedAt: null, + actionId: null, + activeAction: null, + message: null, + }; + + setIpcHandler("admin_list_reports", () => + Promise.resolve([{ ...base, status: "processing" }]), + ); + let detailCalls = 0; + setIpcHandler("admin_get_report", () => { + detailCalls += 1; + return Promise.resolve(detailCalls === 1 ? failedDetail : openDetail); + }); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + let cancelArgs = null; + setIpcHandler("admin_cancel_report", (args) => { + cancelArgs = args; + return Promise.resolve({ + status: "open", + activeAction: { ...failedDetail.activeAction, status: "cancelled" }, + }); + }); + // The dismiss-masquerade path must be gone: resolve must never be called. + let resolveCalled = false; + setIpcHandler("admin_resolve_report", () => { + resolveCalled = true; + return Promise.reject(new Error("resolve must not be called by cancel")); + }); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + // The failed action surfaces the error message and a single Cancel button. + const block = container.querySelector( + "[data-testid='enforcement-state-block']", + ); + assert.ok(block, "enforcement-state-block must render for a failed action"); + assert.ok( + (block.textContent ?? "").includes("adapter timeout"), + `the failure errorMessage must render; got: ${block.textContent}`, + ); + assert.equal( + container.querySelector("[data-testid='enforcement-retry-btn']"), + null, + "the composed-retry button must be gone (Cancel-only on failed)", + ); + const cancelBtn = container.querySelector( + "[data-testid='enforcement-cancel-btn']", + ); + assert.ok(cancelBtn, "the Cancel button must render on a failed action"); + + await act(async () => { + fireEvent.click(cancelBtn); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(20); + + assert.ok(cancelArgs, "admin_cancel_report must be invoked"); + assert.equal(cancelArgs.origin, origin, "origin must be forwarded"); + assert.equal(cancelArgs.id, base.id, "report id must be forwarded"); + assert.equal( + cancelArgs.body?.actionId, + actionId, + "cancel must be fenced on the observed action id", + ); + assert.equal( + resolveCalled, + false, + "cancel must NOT go through the resolve endpoint (no dismiss masquerade)", + ); + assert.ok( + capturedToasts.some((m) => m.toLowerCase().includes("cancel")), + `a cancel success toast must fire; got: ${JSON.stringify(capturedToasts)}`, + ); + // Detail reloaded; the now-open report shows the resolve form for re-triage. + assert.ok(detailCalls >= 2, "detail must reload after cancel"); + assert.ok( + container.querySelector("[data-testid='resolve-report-form']"), + "after cancel the reopened report must show the resolve form", + ); + + await unmount(); +}); + +test("no-cancel-on-in-flight: pending and enforcing actions offer no cancel button", async () => { + // Only a pre-mutation `failed` action is cancellable over HTTP. A stuck + // `pending`/`enforcing` action is owned by the relay's recovery worker; the + // UI must not offer a button that 409s by design. + // + // Mutation evidence: change the button gate from `=== "failed"` to include + // enforcing → the assertion that no cancel button renders goes red. + + const origin = "https://admin.example.com"; + const pubkey = "e6".repeat(32); + + const base = { + id: "00000000-0000-0000-0000-0000000000e6", + communityId: "00000000-0000-0000-0000-000000000002", + communityHost: "relay.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + createdAt: "2024-06-01T12:00:00Z", + }; + const enforcingDetail = { + ...base, + status: "processing", + channelId: null, + note: null, + resolvedBy: null, + resolvedAt: null, + actionId: null, + activeAction: { + id: "00000000-0000-0000-0000-0000000000f3", + requestId: "00000000-0000-0000-0000-0000000000f4", + actorPubkey: + "1111111111111111111111111111111111111111111111111111111111111111", + actorRole: "operator", + action: "ban", + status: "enforcing", + reason: null, + expiresAt: null, + errorMessage: null, + createdAt: "2024-06-01T12:00:00Z", + updatedAt: "2024-06-01T12:00:01Z", + }, + message: null, + }; + + setIpcHandler("admin_list_reports", () => + Promise.resolve([{ ...base, status: "processing" }]), + ); + setIpcHandler("admin_get_report", () => Promise.resolve(enforcingDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + assert.ok( + container.querySelector("[data-testid='enforcement-state-block']"), + "enforcement-state-block must render for an enforcing action", + ); + assert.equal( + container.querySelector("[data-testid='enforcement-cancel-btn']"), + null, + "no cancel button on an in-flight (enforcing) action", + ); + // And the resolve form must stay suppressed on a processing report. + assert.equal( + container.querySelector("[data-testid='resolve-report-form']"), + null, + "resolve form must not render on a processing report", + ); + + await unmount(); +}); + +test("reopened-after-enforcement: an open report carrying a succeeded action shows both history and the resolve form", async () => { + // Honest history: a report enforced then reopened is `open` yet the detail + // LATERAL still returns the succeeded action (the ban actually ran — a later + // reopen does not un-happen it). The UI must render that action as executed + // history AND still offer the resolve form, because the report is open for + // re-triage. Cancel must NOT appear — cancel is failed-only. + // + // Mutation evidence: restore the `isOpen && !activeAction` gate → the resolve + // form vanishes on this report and the operator is stranded, going red. + + const origin = "https://admin.example.com"; + const pubkey = "e7".repeat(32); + + const reopenedDetail = { + id: "00000000-0000-0000-0000-0000000000e7", + communityId: "00000000-0000-0000-0000-000000000002", + communityHost: "relay.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + status: "open", + channelId: null, + note: null, + resolvedBy: null, + resolvedAt: null, + actionId: null, + activeAction: { + id: "00000000-0000-0000-0000-0000000000f5", + requestId: "00000000-0000-0000-0000-0000000000f6", + actorPubkey: + "1111111111111111111111111111111111111111111111111111111111111111", + actorRole: "operator", + action: "ban", + status: "succeeded", + reason: "confirmed spam", + expiresAt: null, + errorMessage: null, + createdAt: "2024-06-01T12:00:00Z", + updatedAt: "2024-06-01T12:00:03Z", + }, + message: null, + createdAt: "2024-06-01T11:00:00Z", + }; + + setIpcHandler("admin_list_reports", () => + Promise.resolve([{ ...reopenedDetail, activeAction: undefined }]), + ); + setIpcHandler("admin_get_report", () => Promise.resolve(reopenedDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + // Executed-enforcement history renders. + const block = container.querySelector( + "[data-testid='enforcement-state-block']", + ); + assert.ok(block, "the succeeded action must render as enforcement history"); + assert.ok( + (block.textContent ?? "").toLowerCase().includes("succeeded"), + `history must show the succeeded state; got: ${block.textContent}`, + ); + // Cancel is failed-only — never on a succeeded action. + assert.equal( + container.querySelector("[data-testid='enforcement-cancel-btn']"), + null, + "no cancel button on a succeeded action", + ); + // The resolve form must still show — the report is open for re-triage. + assert.ok( + container.querySelector("[data-testid='resolve-report-form']"), + "an open reopened-after-enforcement report must still show the resolve form", + ); + + await unmount(); +}); + +test("feedback-severed-community: a purged-source feedback row renders in list and detail without its community", async () => { + // Item 5 (desktop): feedback whose source community was purged carries a + // null communityId/communityHost (tenant provenance severed, row retained as + // operator evidence). The list must still render it (grouped under a + // "source community removed" bucket) and the detail must show em-dashes for + // the absent community fields — never crash on the null. + // + // Mutation evidence: narrow AdminFeedbackDto.communityId back to `string` → + // typecheck breaks; restore the `communityId: string` grouping constraint → + // the null key throws in groupByCommunity. + + const origin = "https://admin.example.com"; + const pubkey = "e8".repeat(32); + + const severedSummary = { + id: "00000000-0000-0000-0000-0000000000e8", + communityId: null, + communityHost: null, + submitterPubkey: "sub-severed", + category: "bug", + bodySummary: "Feedback from a since-purged community", + status: "new", + receivedAt: "2024-06-01T09:00:00Z", + }; + const severedDetail = { + id: severedSummary.id, + communityId: null, + communityHost: null, + eventId: "sevevent", + submitterPubkey: severedSummary.submitterPubkey, + category: "bug", + body: "Feedback from a since-purged community — full body", + status: "new", + tags: [], + eventCreatedAt: "2024-06-01T09:00:00Z", + receivedAt: "2024-06-01T09:00:00Z", + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => Promise.resolve([severedSummary])); + setIpcHandler("admin_get_feedback", () => Promise.resolve(severedDetail)); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + + const feedbackTab = container.querySelector( + "[data-testid='admin-tab-feedback']", + ); + assert.ok(feedbackTab, "Feedback tab must be present"); + await act(async () => { + fireEvent.click(feedbackTab); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(30); + + // The severed row still renders in the list (did not throw / vanish). + const listRow = Array.from(container.querySelectorAll("button")).find( + (btn) => + !(btn.getAttribute("data-testid") ?? "").startsWith("admin-tab") && + btn.textContent?.includes("since-purged community"), + ); + assert.ok(listRow, "the severed feedback row must render in the list"); + + await act(async () => { + fireEvent.click(listRow); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(30); + + // Detail renders; the community fields show the em-dash placeholder. + const fields = container.querySelector( + "[data-testid='feedback-detail-fields']", + ); + assert.ok(fields, "feedback detail must render for a severed row"); + assert.ok( + (fields.textContent ?? "").includes("—"), + `absent community fields must render as em-dash; got: ${fields.textContent}`, + ); + + await unmount(); +}); + +// ── D3a: kick suppressed when the report carries no channel ──────────────── + +test("kick-suppressed-when-channel-null: an event report without a channel hides the Kick action", async () => { + // Kick removes the target from the report's associated channel, so the relay + // 400s (invalid_action_for_target) when the report has no channelId. The + // resolve form must not offer an action guaranteed to fail. Other event + // actions (ban/timeout/dismiss/delete/escalate) stay available. + // + // Mutation evidence: drop the `.filter((a) => a !== "kick" || channelId + // != null)` guard → action-btn-kick renders and the null-channel assertion + // goes red. + + const origin = "https://admin.example.com"; + const pubkey = "d3".repeat(32); + + const item = { + id: "00000000-0000-0000-0000-0000000000d3", + communityId: "comm-1", + communityHost: "alpha.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + status: "open", + createdAt: "2024-06-01T12:00:00Z", + }; + const detail = { + ...item, + channelId: null, + note: null, + resolvedBy: null, + resolvedAt: null, + actionId: null, + message: null, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([item])); + setIpcHandler("admin_get_report", () => Promise.resolve(detail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + assert.ok( + container.querySelector("[data-testid='resolve-report-form']"), + "resolve form must render for an open report", + ); + assert.equal( + container.querySelector("[data-testid='action-btn-kick']"), + null, + "Kick must be suppressed when the report has no channelId", + ); + // Sibling event actions remain available — only Kick is gated. + assert.ok( + container.querySelector("[data-testid='action-btn-ban']"), + "Ban must still be offered on an event report", + ); + + await unmount(); +}); + +test("kick-offered-when-channel-set: an event report with a channel offers the Kick action", async () => { + // The paired case: when the report carries a channelId, Kick is a valid + // action (the relay can enforce it) and must be offered. + + const origin = "https://admin.example.com"; + const pubkey = "d4".repeat(32); + + const item = { + id: "00000000-0000-0000-0000-0000000000d4", + communityId: "comm-1", + communityHost: "alpha.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + status: "open", + createdAt: "2024-06-01T12:00:00Z", + }; + const detail = { + ...item, + channelId: "00000000-0000-0000-0000-0000000000ff", + note: null, + resolvedBy: null, + resolvedAt: null, + actionId: null, + message: null, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([item])); + setIpcHandler("admin_get_report", () => Promise.resolve(detail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + assert.ok( + container.querySelector("[data-testid='action-btn-kick']"), + "Kick must be offered when the report carries a channelId", + ); + + await unmount(); +}); + +// ── D2: lists refetch on back-nav after a mutation ───────────────────────── + +test("reports-list-refetches-on-back-after-mutation: resolving a report then navigating back shows fresh list status", async () => { + // A mutation in the detail bumps a list generation fence propagated to the + // ReportsTab, so returning to the list refetches instead of serving the + // stale cached rows (Will's tab-switch workaround). Evidence is a second + // admin_list_reports call after back-nav returning the updated status. + // + // Mutation evidence: drop the onMutated → setListGen wiring → the list + // query key never changes, admin_list_reports is called once, and the + // second-call assertion goes red. + + const origin = "https://admin.example.com"; + const pubkey = "d5".repeat(32); + + const openItem = { + id: "00000000-0000-0000-0000-0000000000d5", + communityId: "comm-1", + communityHost: "alpha.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "pubkey", + target: "cc", + reportType: "spam", + status: "open", + createdAt: "2024-06-01T12:00:00Z", + }; + const openDetail = { + ...openItem, + channelId: null, + note: null, + resolvedBy: null, + resolvedAt: null, + actionId: null, + message: null, + }; + + // The list returns "open" first, then "dismissed" after the mutation — the + // refetch must surface the new status. + let listCalls = 0; + setIpcHandler("admin_list_reports", () => { + listCalls += 1; + return Promise.resolve([ + { ...openItem, status: listCalls === 1 ? "open" : "dismissed" }, + ]); + }); + setIpcHandler("admin_get_report", () => Promise.resolve(openDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + setIpcHandler("admin_resolve_report", () => + Promise.resolve({ status: "dismissed" }), + ); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + + await openFirstReportDetail(container); + await settle(20); + const callsBeforeBack = listCalls; + + // Dismiss the report. + const dismissBtn = container.querySelector( + "[data-testid='action-btn-dismiss']", + ); + assert.ok(dismissBtn, "dismiss action must be present"); + await act(async () => { + fireEvent.click(dismissBtn); + await new Promise((r) => setTimeout(r, 10)); + }); + const submit = container.querySelector("[data-testid='resolve-submit-btn']"); + assert.ok( + submit, + "resolve submit button must appear after selecting dismiss", + ); + await act(async () => { + fireEvent.click(submit); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(20); + + // Navigate back to the list. + const backBtn = Array.from(container.querySelectorAll("button")).find((b) => + b.textContent?.includes("Back to reports"), + ); + assert.ok(backBtn, "back-to-reports button must be present"); + await act(async () => { + fireEvent.click(backBtn); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(20); + + assert.ok( + listCalls > callsBeforeBack, + `the list must refetch after back-nav following a mutation; before=${callsBeforeBack} after=${listCalls}`, + ); + assert.ok( + (container.textContent ?? "").includes("dismissed"), + `the refetched list must show the updated status; got: ${(container.textContent ?? "").slice(0, 400)}`, + ); + + await unmount(); +}); + +test("feedback-list-refetches-on-back-after-mutation: changing status then navigating back shows fresh list status", async () => { + // Same fence for the Feedback tab: a status change in the detail bumps the + // FeedbackTab list generation so back-nav refetches. + // + // Mutation evidence: drop the FeedbackDetail onMutated → setListGen wiring → + // admin_list_feedback is called once and the second-call assertion goes red. + + const origin = "https://admin.example.com"; + const pubkey = "d6".repeat(32); + + const summary = { + id: "00000000-0000-0000-0000-0000000000d6", + communityId: "00000000-0000-0000-0000-000000000022", + communityHost: "relay.example.com", + submitterPubkey: "submitter", + category: "bug", + bodySummary: "App crashes on startup", + status: "new", + receivedAt: "2024-05-01T09:00:05Z", + }; + const detail = { + id: summary.id, + communityId: summary.communityId, + communityHost: summary.communityHost, + eventId: "feedevent", + submitterPubkey: summary.submitterPubkey, + category: "bug", + body: "App crashes on startup — full detail", + status: "new", + tags: [], + eventCreatedAt: "2024-05-01T09:00:00Z", + receivedAt: "2024-05-01T09:00:05Z", + }; + + let listCalls = 0; + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => { + listCalls += 1; + return Promise.resolve([ + { ...summary, status: listCalls === 1 ? "new" : "reviewed" }, + ]); + }); + setIpcHandler("admin_get_feedback", () => Promise.resolve(detail)); + setIpcHandler("admin_patch_feedback", () => + Promise.resolve({ status: "reviewed" }), + ); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + + // Switch to the Feedback tab. + const feedbackTab = container.querySelector( + "[data-testid='admin-tab-feedback']", + ); + assert.ok(feedbackTab, "Feedback tab must be present"); + await act(async () => { + fireEvent.click(feedbackTab); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(20); + assert.equal(listCalls, 1, "feedback list is fetched once on tab open"); + + // Open the first feedback row. + const row = Array.from(container.querySelectorAll("button")).find( + (b) => + !(b.getAttribute("data-testid") ?? "").startsWith("admin-tab") && + b.textContent?.includes("App crashes"), + ); + assert.ok(row, "feedback row must be present"); + await act(async () => { + fireEvent.click(row); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(20); + + // Mark reviewed. + const reviewedBtn = container.querySelector( + "[data-testid='feedback-status-btn-reviewed']", + ); + assert.ok(reviewedBtn, "reviewed status button must be present"); + await act(async () => { + fireEvent.click(reviewedBtn); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(20); + + // Navigate back to the feedback list. + const backBtn = Array.from(container.querySelectorAll("button")).find((b) => + b.textContent?.includes("Back to feedback"), + ); + assert.ok(backBtn, "back-to-feedback button must be present"); + await act(async () => { + fireEvent.click(backBtn); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(20); + + assert.ok( + listCalls >= 2, + `the feedback list must refetch after back-nav following a status change; listCalls=${listCalls}`, + ); + assert.ok( + (container.textContent ?? "").includes("reviewed"), + `the refetched feedback list must show the updated status; got: ${(container.textContent ?? "").slice(0, 400)}`, + ); + + await unmount(); +}); + +test("resolve-rejection-surfaces-parsed-message: a rejected resolve toasts the relay message, not raw JSON", async () => { + // A resolve mutation that the relay rejects (e.g. invalid_action_for_target) + // must surface the envelope's human message via toast.error — never the raw + // JSON envelope and never a success toast. + // + // Mutation evidence: replace `toast.error(adminErrorMessage(e))` in + // handleSubmit with `toast.error(String(e))` → the raw-JSON assertion goes + // red because the envelope leaks verbatim. + + const origin = "https://admin.example.com"; + const pubkey = "f7".repeat(32); + + const openItem = { + id: "00000000-0000-0000-0000-0000000000f7", + communityId: "comm-1", + communityHost: "alpha.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + status: "open", + createdAt: "2024-06-01T12:00:00Z", + }; + const openDetail = { + ...openItem, + channelId: "00000000-0000-0000-0000-0000000000ff", + note: null, + resolvedBy: null, + resolvedAt: null, + actionId: null, + message: null, + }; + + const humanMessage = + "action kick requires the report to have an associated channel"; + // The native command rejects with a typed AdminMutationError: message is + // `admin API error: {envelope}` (the shape adminErrorMessage strips to the + // envelope's `message`) and relayStatus is the relay's 400. + const rawError = `admin API error: {"error":{"code":"invalid_action_for_target","message":"${humanMessage}","requestId":"00000000-0000-0000-0000-0000000000e7"}}`; + + setIpcHandler("admin_list_reports", () => Promise.resolve([openItem])); + setIpcHandler("admin_get_report", () => Promise.resolve(openDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + setIpcHandler("admin_resolve_report", () => mutationReject(rawError, 400)); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + // Select the kick action, then submit — the relay rejects it. + const kickBtn = container.querySelector("[data-testid='action-btn-kick']"); + assert.ok(kickBtn, "kick action must be present (channel is set)"); + await act(async () => { + fireEvent.click(kickBtn); + await new Promise((r) => setTimeout(r, 10)); + }); + const submit = container.querySelector("[data-testid='resolve-submit-btn']"); + assert.ok(submit, "resolve submit button must appear after selecting kick"); + await act(async () => { + fireEvent.click(submit); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(20); + + // The parsed human message reaches toast.error. + assert.ok( + capturedErrorToasts.some((m) => m.includes(humanMessage)), + `the parsed relay message must surface via toast.error; got: ${JSON.stringify(capturedErrorToasts)}`, + ); + // The raw JSON envelope must NOT leak into any error toast. + assert.ok( + !capturedErrorToasts.some( + (m) => m.includes('{"error"') || m.includes("admin API error:"), + ), + `the raw JSON envelope must not appear in an error toast; got: ${JSON.stringify(capturedErrorToasts)}`, + ); + // No success toast on a rejected resolve. + assert.ok( + !capturedToasts.some((m) => m.toLowerCase().includes("resolved")), + `no success toast on a rejected resolve; got: ${JSON.stringify(capturedToasts)}`, + ); + + await unmount(); +}); diff --git a/desktop/src/features/admin-console/api.ts b/desktop/src/features/admin-console/api.ts new file mode 100644 index 00000000000..d2bc7f5c223 --- /dev/null +++ b/desktop/src/features/admin-console/api.ts @@ -0,0 +1,539 @@ +/** + * TypeScript wrappers for the desktop admin console Tauri commands. + * + * All network activity is native (Rust). The webview never constructs + * admin API URLs — it supplies typed arguments which the Rust layer maps + * to the closed route enum. + * + * State keying: every result is implicitly tied to `(activePubkey, origin)`. + * Callers must cancel in-flight queries on pubkey or origin change. + */ + +import { invokeTauri } from "@/shared/api/tauri"; +import { invoke as invokeTauriRaw } from "@tauri-apps/api/core"; + +// ── Probe ───────────────────────────────────────────────────────────────── + +/** + * Result of probing an admin origin. Each variant drives a distinct settings + * UI state. See `AdminProbeResult` in the Rust module for the full contract. + */ +export type AdminProbeState = + | "nip98Authorized" + | "nip98Denied" + | "tokenMode" + | "disabled" + | "notAdminApi" + | "networkOrIntercepted"; + +/** + * The resolved principal role, present only in `nip98Authorized` state. + * Matches the relay's `operator|moderator` vocabulary. + */ +export type AdminPrincipalRole = "operator" | "moderator"; + +/** + * How the principal's role was resolved — determines whether staffing + * controls are editable in the UI. + */ +export type AdminPrincipalSource = "config" | "owner_fallback" | "db"; + +export type AdminProbeResult = { + state: AdminProbeState; + /** Present when state is `nip98Authorized`. */ + role?: AdminPrincipalRole | null; + /** Present when state is `nip98Authorized`. */ + source?: AdminPrincipalSource | null; +}; + +/** + * Probe `origin` to determine the authentication mode and whether the current + * app keypair is authorised. + * + * Returns `nip98Authorized` only on a fully authenticated 2xx. All other + * states map directly to informational UI copy without further retries. + */ +export async function probeAdminOrigin( + origin: string, +): Promise { + return invokeTauri("admin_probe", { origin }); +} + +// ── Origin persistence ──────────────────────────────────────────────────── + +/** + * Return the saved admin console origin for the currently active pubkey, or + * `null` if none has been saved. + * + * `expectedPubkey` is forwarded to the Rust command as a defence-in-depth + * guard: if the active signing key no longer matches the pubkey that was + * active when the call was issued (delayed IPC after an identity switch), the + * Rust side rejects the read. Callers should pass the pubkey that was active + * when the request was initiated. + */ +export async function getAdminOrigin( + expectedPubkey?: string, +): Promise { + return invokeTauri("get_admin_origin", { expectedPubkey }); +} + +/** + * Validate, normalise, and save `rawOrigin` as the admin console origin for + * the current pubkey. Returns the canonical origin on success. + * Pass `null` to clear the saved origin. + * + * `expectedPubkey` is forwarded to the Rust command: if the active signing + * key no longer matches, the write is rejected so a delayed save cannot write + * identity A's input into identity B's storage namespace. + */ +export async function setAdminOrigin( + rawOrigin: string | null, + expectedPubkey?: string, +): Promise { + return invokeTauri("set_admin_origin", { + rawOrigin, + expectedPubkey, + }); +} + +/** + * Auto-discover the admin console origin from the connected relay's NIP-11 + * document (`admin_api` field). Returns the canonical origin when the relay + * advertises a valid one, or `null` when it does not — the caller falls back + * to manual entry. Rejects only on a transport or relay error; an absent or + * invalid advertised value resolves to `null`, never throws. + */ +export async function discoverAdminOrigin(): Promise { + return invokeTauri("admin_discover_origin"); +} + +// ── Wire DTO types ──────────────────────────────────────────────────────── +// +// Mirror `crates/buzz-db/src/admin_moderation.rs` field-for-field. +// Rust structs use `#[serde(rename_all = "camelCase")]`; DateTime +// serialises to an ISO-8601 string; Option serialises to null / absent. + +/** Deployment-global moderation report (list and detail base). */ +export type AdminReportDto = { + id: string; + communityId: string; + communityHost: string; + reportEventId: string; + reporterPubkey: string; + targetKind: string; + target: string; + channelId?: string | null; + reportType: string; + note?: string | null; + /** + * Report status. Values: `open` | `processing` | `resolved` | `dismissed` | `escalated`. + * A `processing` report has an in-progress enforcement action; it must NOT be + * presented as actionable in the UI. + */ + status: string; + resolvedBy?: string | null; + resolvedAt?: string | null; + actionId?: string | null; + /** + * Present when status is `processing` or the report has an active/failed action. + * Drives the enforcement-state rendering. + */ + activeAction?: AdminActionRecordDto | null; + createdAt: string; +}; + +/** Reported message snapshot attached to an AdminReportDetail. */ +export type AdminReportedMessageDto = { + authorPubkey: string; + content: string; + createdAt: string; + deletedAt?: string | null; +}; + +/** + * Full report detail — AdminReport fields flattened with an optional + * nested message (present when the report targets a stored event). + */ +export type AdminReportDetailDto = AdminReportDto & { + message?: AdminReportedMessageDto | null; +}; + +/** Deployment-global product feedback entry. */ +export type AdminFeedbackDto = { + id: string; + /** + * Source community. Both are `null` once the source community has been + * purged: feedback is deployment-global operator evidence whose + * `communityId` is severed to NULL on tenant purge, not cascade-deleted. + */ + communityId: string | null; + communityHost: string | null; + eventId: string; + submitterPubkey: string; + category?: string | null; + body: string; + /** Triage status: `"new"` | `"reviewed"` | `"archived"`. Always present. */ + status: AdminFeedbackStatus; + /** Full source tags — consumed as imeta attachment metadata. */ + tags: unknown; + eventCreatedAt: string; + receivedAt: string; +}; + +/** + * Feedback list row returned by the relay's `GET /admin/feedback` handler. + * Authoritative source: `buzz-relay/src/api/admin/mod.rs` `FeedbackSummary`. + * + * This is a separate, leaner shape from `AdminFeedbackDto` — the list + * endpoint summarises the body and omits event/tag detail fields that are + * only needed when viewing a single entry. + */ +export type AdminFeedbackSummaryDto = { + id: string; + /** Source community — `null` on a severed (purged-source) row. */ + communityId: string | null; + communityHost: string | null; + submitterPubkey: string; + category?: string | null; + bodySummary: string; + /** Triage status: `"new"` | `"reviewed"` | `"archived"`. Always present. */ + status: AdminFeedbackStatus; + receivedAt: string; +}; + +// ── Data commands ───────────────────────────────────────────────────────── + +export type AdminReportsQuery = { + communityId?: string; + status?: string; + reportType?: string; + targetKind?: string; + after?: string; + before?: string; + limit?: number; +}; + +/** Fetch the deployment-wide reports list. */ +export async function listAdminReports( + origin: string, + query: AdminReportsQuery = {}, +): Promise { + return invokeTauri("admin_list_reports", { origin, query }); +} + +/** Fetch a single report's detail by ID. */ +export async function getAdminReport( + origin: string, + id: string, +): Promise { + return invokeTauri("admin_get_report", { origin, id }); +} + +/** Fetch the deployment-wide product feedback list. */ +export async function listAdminFeedback( + origin: string, +): Promise { + return invokeTauri("admin_list_feedback", { + origin, + }); +} + +/** Fetch a single feedback entry's detail (includes imeta attachment metadata). */ +export async function getAdminFeedback( + origin: string, + id: string, +): Promise { + return invokeTauri("admin_get_feedback", { origin, id }); +} + +// ── Actions ─────────────────────────────────────────────────────────────── + +/** + * Valid actions per target_kind (v4 §7 frozen matrix). + * + * event: delete | kick | ban | timeout | dismiss | escalate + * pubkey: ban | timeout | dismiss | escalate + * blob: dismiss | escalate + */ +export type AdminReportAction = + | "delete" + | "kick" + | "ban" + | "timeout" + | "dismiss" + | "escalate"; + +/** + * Body for POST /api/admin/v1/reports/{id}/resolve. + * + * `requestId` is a client-generated UUID. Generate once per resolution + * attempt and **reuse on retry after a lost response** (v4 amendment 2). + * + * `expirationSecs` is required for `timeout` and must be omitted otherwise. + */ +export type AdminResolveReportBody = { + action: AdminReportAction; + requestId: string; + expirationSecs?: number; + reason?: string; +}; + +/** + * The action record returned in the resolve response (or from the report detail + * when status is `processing`/`failed`). + * + * Field-for-field the relay's serialized action record. In practice `action` is + * always an enforcement action (`delete`/`kick`/`ban`/`timeout`) — `dismiss` and + * `escalate` terminalise the report without creating a record, so they surface as + * `activeAction: null`, never here. + * + * `expiresAt` is the absolute enforcement expiry (`timeout_until`), null except for + * `timeout`. It is distinct from the resolve request's `expirationSecs` input. + */ +export type AdminActionRecordDto = { + id: string; + requestId: string; + actorPubkey: string; + actorRole: AdminPrincipalRole; + action: AdminReportAction; + status: "pending" | "enforcing" | "succeeded" | "failed" | "cancelled"; + reason: string | null; + expiresAt: string | null; + errorMessage: string | null; + createdAt: string; + updatedAt: string; +}; + +/** + * Uniform envelope returned by resolve and cancel: the report's new terminal + * status plus the governing action record. `activeAction` is null for + * decision-only resolutions (`dismiss`/`escalate`, which create no record). + * + * Both endpoints re-read the report so this shape matches a subsequent + * `GET /reports/{id}` — the console reloads detail after a mutation rather than + * consuming this body, so it is a wire contract, not a render source. + */ +export type AdminReportResolution = { + status: string; + activeAction: AdminActionRecordDto | null; +}; + +/** + * Resolve a report — POST /api/admin/v1/reports/{id}/resolve. + * + * The caller must generate a UUID `requestId` per resolution attempt and + * reuse the **same** UUID on retry after a lost response. A different + * `requestId` against a `processing` report yields 409. + */ +export async function resolveAdminReport( + origin: string, + id: string, + body: AdminResolveReportBody, +): Promise { + return invokeTauri("admin_resolve_report", { + origin, + id, + body, + }); +} + +/** + * Body for POST /api/admin/v1/reports/{id}/cancel. + * + * `actionId` fences the cancel to exactly the failed action the operator + * observed. A mismatch — already cancelled, superseded by a newer claim, or + * past the mutation point — resolves to 409. + */ +export type AdminCancelReportBody = { + actionId: string; +}; + +/** + * Cancel a failed enforcement action — POST /api/admin/v1/reports/{id}/cancel. + * + * The only recovery path for a `failed` action: it returns the report to + * `open` for a fresh resolution attempt (there is no composed client-side + * retry — that would imply an atomicity the relay does not provide). A `409` + * means the action is no longer cancellable; treat it as "refresh detail" — + * someone else likely cancelled it or it advanced past the mutation point. + * + * The response embeds the just-cancelled action as a last look; a subsequent + * detail read serves `activeAction: null`. + */ +export async function cancelAdminReport( + origin: string, + id: string, + body: AdminCancelReportBody, +): Promise { + return invokeTauri("admin_cancel_report", { + origin, + id, + body, + }); +} + +/** + * Body for POST /api/admin/v1/reports/{id}/reopen. + * + * `requestId` is a client-generated UUID; generate once per reopen attempt and + * reuse the **same** UUID on retry after a lost response, mirroring resolve + * idempotency. + */ +export type AdminReopenReportBody = { + requestId: string; + reason?: string; +}; + +/** The status returned by a successful reopen — always `"open"`. */ +export type AdminReopenReportResult = { + status: string; +}; + +/** + * Reopen a resolved report — POST /api/admin/v1/reports/{id}/reopen. + * + * Moves a `resolved` | `dismissed` | `escalated` report back to `open` for + * re-triage. A `processing` report is not reopenable and yields 409. Reopen + * does **not** reverse enforcement (no un-ban, no un-delete) — it only + * re-queues the report. + * + * The caller must generate a UUID `requestId` per reopen attempt and reuse the + * **same** UUID on retry after a lost response. + */ +export async function reopenAdminReport( + origin: string, + id: string, + body: AdminReopenReportBody, +): Promise { + return invokeTauri("admin_reopen_report", { + origin, + id, + body, + }); +} + +// ── Feedback status ─────────────────────────────────────────────────────── + +export type AdminFeedbackStatus = "new" | "reviewed" | "archived"; + +/** + * The PATCH /api/admin/v1/feedback/{id} response — the relay echoes only the + * updated `status`, not a full feedback record. + */ +export type AdminFeedbackStatusResult = { + status: AdminFeedbackStatus; +}; + +/** Update feedback status — PATCH /api/admin/v1/feedback/{id}. */ +export async function patchAdminFeedback( + origin: string, + id: string, + status: AdminFeedbackStatus, +): Promise { + return invokeTauri("admin_patch_feedback", { + origin, + id, + body: { status }, + }); +} + +// ── Staffing ────────────────────────────────────────────────────────────── + +/** + * An effective principal entry returned by GET /api/admin/v1/operators. + * `effectiveRole` is the resolved role; `sources` explains where it comes from. + */ +export type AdminOperatorDto = { + pubkey: string; + effectiveRole: "operator" | "moderator"; + sources: Array<"config" | "owner_fallback" | "db">; +}; + +/** List all effective principals — GET /api/admin/v1/operators. Operator-only. */ +export async function listAdminOperators( + origin: string, +): Promise { + return invokeTauri("admin_list_operators", { origin }); +} + +/** + * Add or update an operator — PUT /api/admin/v1/operators/{pubkey}. + * Returns 409 (as a thrown error string) if the pubkey is config-backed. + */ +export async function putAdminOperator( + origin: string, + pubkey: string, + role: "operator" | "moderator", +): Promise { + return invokeTauri("admin_put_operator", { + origin, + pubkey, + body: { role }, + }); +} + +/** + * Remove an operator — DELETE /api/admin/v1/operators/{pubkey}. + * Returns 409 (as a thrown error string) if the pubkey is config-backed. + */ +export async function deleteAdminOperator( + origin: string, + pubkey: string, +): Promise { + return invokeTauri("admin_delete_operator", { origin, pubkey }); +} + +// ── Attachment ──────────────────────────────────────────────────────────── + +/** + * Stable typed error codes returned by `admin_fetch_feedback_attachment`. + * These map to actionable UI states — never silently ignored. + */ +export type AdminAttachmentErrorCode = + | "admin_attachment_too_large" + | "admin_attachment_mime_mismatch" + | "admin_attachment_size_mismatch" + | "admin_attachment_invalid_hash" + | "admin_attachment_invalid_mime" + | "admin_attachment_invalid_size" + | "admin_attachment_network_error" + | "admin_attachment_redirect" + | string; // relay HTTP error codes like admin_attachment_relay_error_404 + +/** + * Fetch a feedback attachment as raw bytes, then construct a Blob URL. + * + * The caller MUST supply `expectedMime` and `expectedSize` from the + * server-validated `imeta` fields in the feedback detail response. The native + * layer validates the relay's `Content-Type` and byte count against these + * expected values before returning; a mismatch yields a typed error code. + * + * The Blob is constructed from `expectedMime` — never a response header — + * so MIME is anchored to the server-validated imeta metadata. + * + * **Callers must `URL.revokeObjectURL(url)` when the URL is no longer needed.** + * + * @returns A `blob:` URL on success. + * @throws The typed error code string on failure. + */ +export async function fetchAdminAttachmentBlobUrl( + origin: string, + feedbackId: string, + sha256: string, + expectedMime: string, + expectedSize: number, +): Promise { + // The Rust command returns `tauri::ipc::Response` — arrives as ArrayBuffer. + const buffer = await invokeTauriRaw( + "admin_fetch_feedback_attachment", + { + origin, + feedbackId, + sha256, + expectedMime, + expectedSize, + }, + ); + const blob = new Blob([buffer], { type: expectedMime }); + return URL.createObjectURL(blob); +} diff --git a/desktop/src/features/admin-console/errorMessage.test.mjs b/desktop/src/features/admin-console/errorMessage.test.mjs new file mode 100644 index 00000000000..bcb6464db83 --- /dev/null +++ b/desktop/src/features/admin-console/errorMessage.test.mjs @@ -0,0 +1,54 @@ +/** + * Unit tests for adminErrorMessage — the parser that turns a native admin + * mutation rejection into the human-readable text surfaced via toast.error. + * + * Native admin commands reject with `admin API error: {json}` where the JSON + * is the relay's error envelope. The parser strips the prefix and returns the + * envelope's `message`, falling back to the raw string for anything that is + * not that shape (network errors, plain strings, malformed JSON). + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { adminErrorMessage } from "./AdminConsolePanelHelpers.tsx"; + +test("extracts-envelope-message: returns the relay error message, not the raw JSON", () => { + const raw = + 'admin API error: {"error":{"code":"invalid_action_for_target","message":"action kick requires the report to have an associated channel","requestId":"abc"}}'; + assert.equal( + adminErrorMessage(new Error(raw)), + "action kick requires the report to have an associated channel", + ); +}); + +test("accepts-raw-string-input: parses when passed a string rather than an Error", () => { + const raw = + 'admin API error: {"error":{"message":"report is not open (current status: processing)"}}'; + assert.equal( + adminErrorMessage(raw), + "report is not open (current status: processing)", + ); +}); + +test("falls-back-on-non-json: a plain network error returns its raw text", () => { + assert.equal( + adminErrorMessage(new Error("Failed to fetch")), + "Failed to fetch", + ); +}); + +test("falls-back-on-malformed-json: an unparseable brace payload returns raw", () => { + const raw = "admin API error: {not valid json"; + assert.equal(adminErrorMessage(new Error(raw)), raw); +}); + +test("falls-back-on-empty-message: an envelope with a blank message returns raw", () => { + const raw = 'admin API error: {"error":{"code":"x","message":""}}'; + assert.equal(adminErrorMessage(new Error(raw)), raw); +}); + +test("falls-back-on-absent-message: an envelope without a message field returns raw", () => { + const raw = 'admin API error: {"error":{"code":"internal"}}'; + assert.equal(adminErrorMessage(new Error(raw)), raw); +}); diff --git a/desktop/src/features/admin-console/grouping.test.mjs b/desktop/src/features/admin-console/grouping.test.mjs new file mode 100644 index 00000000000..b3f3cfc8ba2 --- /dev/null +++ b/desktop/src/features/admin-console/grouping.test.mjs @@ -0,0 +1,56 @@ +/** + * Unit tests for community grouping of deployment-wide admin rows. + * + * The admin API returns reports and feedback across every community on the + * deployment; the console buckets them by community for triage. Grouping must + * preserve first-seen community order and server row order within a community, + * and never head a group with an empty host. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { groupByCommunity } from "./AdminConsolePanelHelpers.tsx"; + +test("group-empty-returns-empty: no rows → no groups", () => { + assert.deepEqual(groupByCommunity([]), []); +}); + +test("group-single-community-one-bucket: rows sharing a community collapse to one group", () => { + const rows = [ + { communityId: "c1", communityHost: "a.example.com", id: "r1" }, + { communityId: "c1", communityHost: "a.example.com", id: "r2" }, + ]; + const groups = groupByCommunity(rows); + assert.equal(groups.length, 1); + assert.equal(groups[0].communityId, "c1"); + assert.equal(groups[0].communityHost, "a.example.com"); + assert.deepEqual( + groups[0].items.map((r) => r.id), + ["r1", "r2"], + ); +}); + +test("group-preserves-first-seen-order: communities keep the order they first appear", () => { + const rows = [ + { communityId: "c2", communityHost: "b.example.com", id: "r1" }, + { communityId: "c1", communityHost: "a.example.com", id: "r2" }, + { communityId: "c2", communityHost: "b.example.com", id: "r3" }, + ]; + const groups = groupByCommunity(rows); + assert.deepEqual( + groups.map((g) => g.communityId), + ["c2", "c1"], + ); + // Interleaved rows for c2 stay together in server order. + assert.deepEqual( + groups[0].items.map((r) => r.id), + ["r1", "r3"], + ); +}); + +test("group-blank-host-falls-back-to-id: an empty host never heads a group", () => { + const rows = [{ communityId: "c1", communityHost: "", id: "r1" }]; + const groups = groupByCommunity(rows); + assert.equal(groups[0].communityHost, "c1"); +}); diff --git a/desktop/src/features/admin-console/hooks.test.mjs b/desktop/src/features/admin-console/hooks.test.mjs new file mode 100644 index 00000000000..4d6e8215268 --- /dev/null +++ b/desktop/src/features/admin-console/hooks.test.mjs @@ -0,0 +1,49 @@ +/** + * Unit tests for the Moderation nav resolver's cache key. + * + * The resolver's verdict depends on NIP-11 discovery, which is relay-specific. + * The cache key MUST therefore include the connected relay origin so a + * workspace switch busts the cached verdict instead of serving the previous + * relay's answer for up to `staleTime`. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { moderationNavResolutionQueryKey } from "./hooks.ts"; + +test("query-key-scoped-to-relay: switching relay origin yields a distinct cache key", () => { + const pubkey = "a".repeat(64); + const keyA = moderationNavResolutionQueryKey( + pubkey, + "https://relay-a.example", + ); + const keyB = moderationNavResolutionQueryKey( + pubkey, + "https://relay-b.example", + ); + assert.notDeepEqual( + keyA, + keyB, + "same pubkey on a different relay must not reuse the cached verdict", + ); +}); + +test("query-key-stable-per-relay: same pubkey and relay yields an equal key", () => { + const pubkey = "b".repeat(64); + const origin = "https://relay.example"; + assert.deepEqual( + moderationNavResolutionQueryKey(pubkey, origin), + moderationNavResolutionQueryKey(pubkey, origin), + "a stable identity+relay pair must hit the same cache entry", + ); +}); + +test("query-key-distinguishes-unresolved-relay: a null origin is its own key dimension", () => { + const pubkey = "c".repeat(64); + assert.notDeepEqual( + moderationNavResolutionQueryKey(pubkey, null), + moderationNavResolutionQueryKey(pubkey, "https://relay.example"), + "an unresolved relay must not share a cache entry with a resolved one", + ); +}); diff --git a/desktop/src/features/admin-console/hooks.ts b/desktop/src/features/admin-console/hooks.ts new file mode 100644 index 00000000000..9e5b479ab46 --- /dev/null +++ b/desktop/src/features/admin-console/hooks.ts @@ -0,0 +1,64 @@ +import { useQuery } from "@tanstack/react-query"; + +import { useIdentityQuery } from "@/shared/api/hooks"; +import { useRelayOrigin } from "@/shared/lib/useRelayOrigin"; +import { discoverAdminOrigin, getAdminOrigin, probeAdminOrigin } from "./api"; +import type { ModerationNavResolution } from "./nav"; + +export const moderationNavResolutionQueryKey = ( + pubkeyHex: string, + relayOrigin: string | null, +) => ["moderationNavResolution", pubkeyHex, relayOrigin] as const; + +/** + * Resolve the origin + probe state that decide whether the Moderation nav + * entry is visible. Mirrors the settings card's mount resolution: a saved + * manual origin wins outright (and short-circuits the probe, since the gate + * shows the entry regardless); otherwise NIP-11 discovery is attempted and, + * when it advertises an origin, probed so the gate can distinguish an + * authorized relay from a definitive non-admin verdict. + * + * Keyed by pubkey **and the connected relay origin**: NIP-11 discovery is + * relay-dependent, so a pubkey-only key would serve the previous relay's + * verdict for up to `staleTime` after a workspace switch. Gating `enabled` + * on the relay origin also defers resolution until the relay identity is + * known, so no verdict is computed against an unresolved relay. Errors from + * the probe resolve to a `"error"` outcome rather than rejecting — a + * transport flake must keep the entry visible, not make the whole query fail. + */ +export function useModerationNavResolution(): + | ModerationNavResolution + | undefined { + const { data: identity } = useIdentityQuery(); + const pubkeyHex = identity?.pubkey ?? ""; + const relayOrigin = useRelayOrigin(); + + const query = useQuery({ + enabled: pubkeyHex.length > 0 && relayOrigin != null, + queryKey: moderationNavResolutionQueryKey(pubkeyHex, relayOrigin), + staleTime: 60_000, + queryFn: async (): Promise => { + const saved = await getAdminOrigin(pubkeyHex); + if (saved) { + return { originSource: "saved", probe: null }; + } + let discovered: string | null = null; + try { + discovered = await discoverAdminOrigin(); + } catch { + discovered = null; + } + if (!discovered) { + return { originSource: "none", probe: null }; + } + try { + const result = await probeAdminOrigin(discovered); + return { originSource: "advertised", probe: result.state }; + } catch { + return { originSource: "advertised", probe: "error" }; + } + }, + }); + + return query.data; +} diff --git a/desktop/src/features/admin-console/nav.test.mjs b/desktop/src/features/admin-console/nav.test.mjs new file mode 100644 index 00000000000..4e79e5793e4 --- /dev/null +++ b/desktop/src/features/admin-console/nav.test.mjs @@ -0,0 +1,88 @@ +/** + * Unit tests for the Settings → Moderation nav visibility gate. + * + * The gate decides whether ordinary members ever see the Moderation entry. + * Its two load-bearing rules are: + * 1. A saved manual origin always shows the entry — the Advanced control that + * edits/clears a bad saved URL lives inside the surface, so hiding it would + * permanently lock a user out of fixing their own state. + * 2. An advertised-only origin follows the probe: visible on a plausible + * authorization or a transport flake, hidden on a definitive non-admin + * verdict. Flake must never silently hide the entry. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { shouldShowModerationNav } from "./nav.ts"; + +test("no-origin-hides-entry: neither advertised nor saved → hidden", () => { + assert.equal( + shouldShowModerationNav({ originSource: "none", probe: null }), + false, + ); +}); + +test("saved-origin-authorized-shows-entry: saved manual origin visible", () => { + assert.equal( + shouldShowModerationNav({ + originSource: "saved", + probe: "nip98Authorized", + }), + true, + ); +}); + +test("saved-origin-bad-probe-still-shows-entry: notAdminApi under a saved origin stays visible so the user can fix it", () => { + for (const probe of ["notAdminApi", "nip98Denied", "tokenMode", "error"]) { + assert.equal( + shouldShowModerationNav({ originSource: "saved", probe }), + true, + `saved origin must stay visible for probe=${probe}`, + ); + } +}); + +test("advertised-authorized-shows-entry: advertised origin that authorizes is visible", () => { + assert.equal( + shouldShowModerationNav({ + originSource: "advertised", + probe: "nip98Authorized", + }), + true, + ); +}); + +test("advertised-disabled-shows-entry: advertised origin in auth-disabled mode is visible", () => { + assert.equal( + shouldShowModerationNav({ originSource: "advertised", probe: "disabled" }), + true, + ); +}); + +test("advertised-flake-shows-entry: transport flake never silently hides an advertised entry", () => { + for (const probe of ["networkOrIntercepted", "error"]) { + assert.equal( + shouldShowModerationNav({ originSource: "advertised", probe }), + true, + `advertised origin must stay visible for flake probe=${probe}`, + ); + } +}); + +test("advertised-denied-hides-entry: a definitive non-admin verdict hides an advertised-only entry", () => { + for (const probe of ["nip98Denied", "tokenMode", "notAdminApi"]) { + assert.equal( + shouldShowModerationNav({ originSource: "advertised", probe }), + false, + `advertised origin must hide for definitive verdict probe=${probe}`, + ); + } +}); + +test("advertised-null-probe-hides-entry: fail-closed on an unresolved probe", () => { + assert.equal( + shouldShowModerationNav({ originSource: "advertised", probe: null }), + false, + ); +}); diff --git a/desktop/src/features/admin-console/nav.ts b/desktop/src/features/admin-console/nav.ts new file mode 100644 index 00000000000..fba66c2ed24 --- /dev/null +++ b/desktop/src/features/admin-console/nav.ts @@ -0,0 +1,51 @@ +/** + * Pure visibility logic for the Settings → Moderation nav entry. + * + * Kept free of React and IO so the gate decision is unit-testable in + * isolation; `hooks.ts` resolves the origin + probe state that feed it. + */ + +import type { AdminProbeState } from "./api"; + +/** Where the admin origin came from for the active identity. */ +export type ModerationOriginSource = "saved" | "advertised" | "none"; + +/** + * Probe outcome for the resolved origin. `"error"` distinguishes a probe that + * threw (transport flake) from a definitive relay verdict; `null` means no + * probe was run (no origin, or a saved origin that wins without probing). + */ +export type ModerationProbeOutcome = AdminProbeState | "error" | null; + +export type ModerationNavResolution = { + originSource: ModerationOriginSource; + probe: ModerationProbeOutcome; +}; + +/** + * Decide whether the Moderation nav entry is visible. + * + * - No origin (neither saved-manual nor advertised) → hidden. Ordinary members + * never see a dead entry. + * - A saved manual origin always shows the entry, regardless of probe state: + * the Advanced affordance that edits/clears the origin lives inside the + * surface, so hiding it would lock a user out of fixing a bad saved URL. + * - An advertised-only origin shows the entry when the probe plausibly + * authorizes (`nip98Authorized`/`disabled`) or is a transport flake + * (`networkOrIntercepted`/`error` — never a silent disappear on flake), and + * hides it on a definitive non-admin verdict (`nip98Denied`/`tokenMode`/ + * `notAdminApi`). Fail-closed: any unrecognised outcome hides the entry. + */ +export function shouldShowModerationNav(res: ModerationNavResolution): boolean { + if (res.originSource === "none") return false; + if (res.originSource === "saved") return true; + switch (res.probe) { + case "nip98Authorized": + case "disabled": + case "networkOrIntercepted": + case "error": + return true; + default: + return false; + } +} diff --git a/desktop/src/features/moderation/hooks.ts b/desktop/src/features/moderation/hooks.ts index add59113df7..d8140b8f3e7 100644 --- a/desktop/src/features/moderation/hooks.ts +++ b/desktop/src/features/moderation/hooks.ts @@ -3,24 +3,14 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { getRelaySelf } from "@/features/moderation/lib/relaySelf"; import { banMember, - type CommunityRestriction, - listAuditActions, - listReports, listRestrictions, - type ModerationAction, - type ModerationReport, type ReportType, - type ResolutionAction, - type ResolutionStatus, - resolveReport, submitReport, timeoutMember, unbanMember, untimeoutMember, } from "@/shared/api/moderation"; -export const moderationReportsQueryKey = ["moderationReports"] as const; -export const moderationAuditQueryKey = ["moderationAudit"] as const; export const moderationRestrictionsQueryKey = [ "moderationRestrictions", ] as const; @@ -41,32 +31,7 @@ export function useRelaySelfQuery(enabled = true) { }); } -// --- Reads (mod-authz gated; consumed by the U2 queue/audit surfaces) --- - -export function useModerationReportsQuery( - options?: { status?: string; limit?: number }, - enabled = true, -) { - return useQuery({ - enabled, - queryKey: [ - ...moderationReportsQueryKey, - options?.status ?? null, - options?.limit ?? null, - ], - queryFn: () => listReports(options), - staleTime: 15_000, - }); -} - -export function useModerationAuditQuery(limit?: number, enabled = true) { - return useQuery({ - enabled, - queryKey: [...moderationAuditQueryKey, limit ?? null], - queryFn: () => listAuditActions(limit), - staleTime: 15_000, - }); -} +// --- Reads (mod-authz gated; consumed by the members-sidebar surfaces) --- export function useModerationRestrictionsQuery(enabled = true) { return useQuery({ @@ -80,19 +45,15 @@ export function useModerationRestrictionsQuery(enabled = true) { // --- Writes --- // // Moderation writes are relay-validated command events whose effects surface in -// the queue/audit/restricted reads after processing, so mutations invalidate the -// affected read queries on success rather than fabricating optimistic rows. +// the restriction reads after processing, so mutations invalidate the affected +// read queries on success rather than fabricating optimistic rows. function useInvalidateModerationReads() { const queryClient = useQueryClient(); return () => - Promise.all([ - queryClient.invalidateQueries({ queryKey: moderationReportsQueryKey }), - queryClient.invalidateQueries({ queryKey: moderationAuditQueryKey }), - queryClient.invalidateQueries({ - queryKey: moderationRestrictionsQueryKey, - }), - ]); + queryClient.invalidateQueries({ + queryKey: moderationRestrictionsQueryKey, + }); } /** Submit a NIP-56 report. Does not touch the mod-gated read caches. */ @@ -147,24 +108,4 @@ export function useUntimeoutMemberMutation() { }); } -export function useResolveReportMutation() { - const invalidate = useInvalidateModerationReads(); - return useMutation({ - mutationFn: (input: { - reportEventId: string; - status: ResolutionStatus; - action: ResolutionAction; - reason?: string; - }) => resolveReport(input), - onSuccess: invalidate, - }); -} - -export type { - CommunityRestriction, - ModerationAction, - ModerationReport, - ReportType, - ResolutionAction, - ResolutionStatus, -}; +export type { ReportType }; diff --git a/desktop/src/features/settings/lib/moderationQueue.test.mjs b/desktop/src/features/settings/lib/moderationQueue.test.mjs deleted file mode 100644 index 85ae8a2b911..00000000000 --- a/desktop/src/features/settings/lib/moderationQueue.test.mjs +++ /dev/null @@ -1,257 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; - -import { - buildModerationQueue, - groupTopReportType, - isOpenReport, - reportSeverity, - reportTypeLabel, - resolvableActions, - severityTier, - targetKey, -} from "./moderationQueue.ts"; - -function report(overrides = {}) { - return { - id: overrides.id ?? "r1", - reportEventId: overrides.reportEventId ?? "e".repeat(64), - reporterPubkey: overrides.reporterPubkey ?? "a".repeat(64), - targetKind: overrides.targetKind ?? "event", - target: overrides.target ?? "t".repeat(64), - channelId: overrides.channelId ?? null, - reportType: overrides.reportType ?? "spam", - note: overrides.note ?? null, - status: overrides.status ?? "open", - resolvedBy: overrides.resolvedBy ?? null, - resolvedAt: overrides.resolvedAt ?? null, - actionId: overrides.actionId ?? null, - createdAt: overrides.createdAt ?? "2026-07-07T00:00:00.000Z", - }; -} - -function action(overrides = {}) { - return { - id: overrides.id ?? "a1", - actorPubkey: overrides.actorPubkey ?? "b".repeat(64), - action: overrides.action ?? "timeout", - targetPubkey: overrides.targetPubkey ?? null, - targetEventId: overrides.targetEventId ?? null, - channelId: overrides.channelId ?? null, - reasonCode: overrides.reasonCode ?? null, - publicReason: overrides.publicReason ?? null, - privateReason: overrides.privateReason ?? null, - matchedPrincipal: overrides.matchedPrincipal ?? null, - createdAt: overrides.createdAt ?? "2026-07-06T00:00:00.000Z", - }; -} - -test("reportSeverity: illegal outranks all; other is lowest", () => { - assert.ok(reportSeverity("illegal") > reportSeverity("malware")); - assert.ok(reportSeverity("malware") > reportSeverity("spam")); - assert.ok(reportSeverity("spam") > reportSeverity("profanity")); - assert.ok(reportSeverity("profanity") > reportSeverity("other")); - assert.equal(reportSeverity("other"), 0); -}); - -test("targetKey is kind-qualified so event/pubkey with same hex don't collide", () => { - const hex = "c".repeat(64); - assert.notEqual( - targetKey(report({ targetKind: "event", target: hex })), - targetKey(report({ targetKind: "pubkey", target: hex })), - ); -}); - -test("buildModerationQueue collapses reports about the same target into one group", () => { - const t = "d".repeat(64); - const groups = buildModerationQueue([ - report({ id: "r1", target: t, reporterPubkey: "1".repeat(64) }), - report({ id: "r2", target: t, reporterPubkey: "2".repeat(64) }), - ]); - assert.equal(groups.length, 1); - assert.equal(groups[0].reports.length, 2); -}); - -test("group maxSeverity is the highest among its reports", () => { - const t = "d".repeat(64); - const [group] = buildModerationQueue([ - report({ id: "r1", target: t, reportType: "spam" }), - report({ id: "r2", target: t, reportType: "illegal" }), - ]); - assert.equal(group.maxSeverity, reportSeverity("illegal")); -}); - -test("groups sort by severity desc, then most-recent report desc", () => { - const groups = buildModerationQueue([ - report({ - id: "low", - target: "1".repeat(64), - reportType: "profanity", - createdAt: "2026-07-07T09:00:00.000Z", - }), - report({ - id: "high", - target: "2".repeat(64), - reportType: "illegal", - createdAt: "2026-07-07T01:00:00.000Z", - }), - report({ - id: "midNew", - target: "3".repeat(64), - reportType: "spam", - createdAt: "2026-07-07T10:00:00.000Z", - }), - report({ - id: "midOld", - target: "4".repeat(64), - reportType: "spam", - createdAt: "2026-07-07T02:00:00.000Z", - }), - ]); - assert.deepEqual( - groups.map((g) => g.reports[0].id), - ["high", "midNew", "midOld", "low"], - ); -}); - -test("reports within a group are newest-first; latestCreatedAt reflects that", () => { - const t = "d".repeat(64); - const [group] = buildModerationQueue([ - report({ id: "old", target: t, createdAt: "2026-07-01T00:00:00.000Z" }), - report({ id: "new", target: t, createdAt: "2026-07-05T00:00:00.000Z" }), - ]); - assert.equal(group.reports[0].id, "new"); - assert.equal(group.latestCreatedAt, "2026-07-05T00:00:00.000Z"); -}); - -test("prior actions correlate to event-targeted groups via targetEventId", () => { - const eventId = "e".repeat(64); - const [group] = buildModerationQueue( - [report({ targetKind: "event", target: eventId })], - [ - action({ - id: "match", - targetEventId: eventId, - createdAt: "2026-07-02T00:00:00.000Z", - }), - action({ - id: "matchNewer", - targetEventId: eventId, - createdAt: "2026-07-04T00:00:00.000Z", - }), - action({ id: "other", targetEventId: "f".repeat(64) }), - ], - ); - assert.deepEqual( - group.priorActions.map((a) => a.id), - ["matchNewer", "match"], - ); -}); - -test("prior actions correlate to pubkey-targeted groups via targetPubkey", () => { - const pk = "9".repeat(64); - const [group] = buildModerationQueue( - [report({ targetKind: "pubkey", target: pk })], - [action({ id: "ban", action: "ban", targetPubkey: pk })], - ); - assert.deepEqual( - group.priorActions.map((a) => a.id), - ["ban"], - ); -}); - -test("blob-targeted groups surface no prior-actions correlation (audit has no blob key)", () => { - const sha = "7".repeat(64); - const [group] = buildModerationQueue( - [report({ targetKind: "blob", target: sha })], - [action({ targetEventId: sha }), action({ targetPubkey: sha })], - ); - assert.equal(group.priorActions.length, 0); -}); - -test("isOpenReport is true only for open status", () => { - assert.equal(isOpenReport(report({ status: "open" })), true); - assert.equal(isOpenReport(report({ status: "resolved" })), false); - assert.equal(isOpenReport(report({ status: "escalated" })), false); -}); - -test("empty input yields empty queue", () => { - assert.deepEqual(buildModerationQueue([]), []); -}); - -test("reportTypeLabel covers every category", () => { - for (const t of [ - "illegal", - "nudity", - "malware", - "spam", - "impersonation", - "profanity", - "other", - ]) { - assert.equal(typeof reportTypeLabel(t), "string"); - assert.ok(reportTypeLabel(t).length > 0); - } -}); - -test("severityTier: illegal=critical, malware/impersonation=high, rest=normal", () => { - assert.equal(severityTier("illegal"), "critical"); - assert.equal(severityTier("malware"), "high"); - assert.equal(severityTier("impersonation"), "high"); - assert.equal(severityTier("spam"), "normal"); - assert.equal(severityTier("nudity"), "normal"); - assert.equal(severityTier("profanity"), "normal"); - assert.equal(severityTier("other"), "normal"); -}); - -test("groupTopReportType returns the most severe type in a group", () => { - const t = "d".repeat(64); - const [group] = buildModerationQueue([ - report({ id: "r1", target: t, reportType: "spam" }), - report({ id: "r2", target: t, reportType: "impersonation" }), - report({ id: "r3", target: t, reportType: "profanity" }), - ]); - assert.equal(groupTopReportType(group), "impersonation"); -}); - -test("resolvableActions: event target with a channel offers the full enforceable set", () => { - const actions = resolvableActions("event", true); - assert.deepEqual(actions, ["delete", "ban", "kick", "escalate", "dismiss"]); -}); - -test("resolvableActions: event target without a channel drops the channel-scoped enforcements", () => { - // Defensive: an event report should always carry a channel, but if it - // doesn't, delete (9005) and kick (9001) have nowhere to land. - const actions = resolvableActions("event", false); - assert.deepEqual(actions, ["ban", "escalate", "dismiss"]); -}); - -test("resolvableActions: pubkey target offers ban but never delete or kick", () => { - // A pubkey report is not tied to a channel and points at no event, so the - // channel-scoped delete/kick are structurally impossible. - const actions = resolvableActions("pubkey", false); - assert.deepEqual(actions, ["ban", "escalate", "dismiss"]); - assert.ok(!actions.includes("delete")); - assert.ok(!actions.includes("kick")); -}); - -test("resolvableActions: blob target offers only decision-only resolutions", () => { - const actions = resolvableActions("blob", false); - assert.deepEqual(actions, ["escalate", "dismiss"]); -}); - -test("resolvableActions: timeout is never offered from one-click yet", () => { - for (const kind of ["event", "pubkey", "blob"]) { - for (const hasChannel of [true, false]) { - assert.ok(!resolvableActions(kind, hasChannel).includes("timeout")); - } - } -}); - -test("buildModerationQueue carries channelId from the report onto the group", () => { - const t = "d".repeat(64); - const [group] = buildModerationQueue([ - report({ target: t, targetKind: "event", channelId: "chan-1" }), - ]); - assert.equal(group.channelId, "chan-1"); -}); diff --git a/desktop/src/features/settings/lib/moderationQueue.ts b/desktop/src/features/settings/lib/moderationQueue.ts deleted file mode 100644 index 9ef377e4571..00000000000 --- a/desktop/src/features/settings/lib/moderationQueue.ts +++ /dev/null @@ -1,264 +0,0 @@ -// Domain logic for the community-moderation admin queue (U2 admin surface). -// -// Pure, hook-free transforms over the NIP-98 `/moderation/*` read contract so -// they can be unit-tested without a relay. The authoritative wire row shapes -// live in `@/shared/api/moderation` (Dawn's lane); this module owns only the -// triage math: severity ordering, grouping by target, and prior-actions -// correlation. It reuses those row types directly, narrowing just the two -// fields the triage math dispatches on (`reportType`, `status`) to the precise -// unions below — the shared types keep them as `string` so the wire can carry -// values the client doesn't yet model. -// -// Privacy invariant (locked, Tyler 2026-07-07): `reporterPubkey` is visible in -// this admin queue but MUST NEVER reach any surface the reported author can -// see. Nothing here is rendered author-side. - -import type { - ModerationAction as ApiModerationAction, - ModerationReport as ApiModerationReport, - ResolutionAction, -} from "@/shared/api/moderation"; - -/** NIP-56 report categories accepted at ingest (relay `report.rs::REPORT_TYPES`). */ -export type ReportType = - | "illegal" - | "nudity" - | "malware" - | "spam" - | "impersonation" - | "profanity" - | "other"; - -/** Discriminant for what a report points at (`report_json.target_kind`). */ -export type ReportTargetKind = "event" | "pubkey" | "blob"; - -/** - * Report lifecycle status (DB CHECK on `moderation_reports.status`). `open` is - * the default and the only actionable state; `escalated` routes out of - * community discretion into the platform-safety lane. - */ -export type ReportStatus = "open" | "resolved" | "dismissed" | "escalated"; - -/** Queue row: one accepted kind:1984 report (`/moderation/reports`). - * - * The shared `ApiModerationReport` shape verbatim, with `reportType` and - * `status` narrowed to the client-modeled unions the triage math dispatches - * on. `targetKind` is already the exact union upstream, so it passes through. - */ -export type ModerationReport = Omit< - ApiModerationReport, - "reportType" | "status" -> & { - reportType: ReportType; - status: ReportStatus; -}; - -/** Audit row: one accepted moderation action (`/moderation/audit`). The shared - * shape needs no narrowing here — the triage math treats `action` opaquely. */ -export type ModerationAction = ApiModerationAction; - -/** - * Severity rank per report category — higher acts first. `illegal` tops the - * queue because it routes to the platform-safety escalation lane, not - * community discretion (Eva's two-layer model). The rest descend by typical - * community harm. `other` sinks to the bottom as the catch-all. - */ -const SEVERITY_RANK: Record = { - illegal: 6, - malware: 5, - impersonation: 4, - nudity: 3, - spam: 2, - profanity: 1, - other: 0, -}; - -export function reportSeverity(reportType: ReportType): number { - return SEVERITY_RANK[reportType] ?? SEVERITY_RANK.other; -} - -/** - * Stable identity for the *thing* a report targets, so multiple reports about - * the same message/user/blob collapse into one queue group. Kind-qualified to - * keep an event id and a (hypothetical) identical pubkey hex from colliding. - */ -export function targetKey(report: ModerationReport): string { - return `${report.targetKind}:${report.target}`; -} - -export type ModerationQueueGroup = { - targetKey: string; - targetKind: ReportTargetKind; - target: string; - /** - * Channel the target lives in, if any. An event target lives in exactly one - * channel (all reports about it agree), so we take it from the first report; - * pubkey/blob targets are not channel-scoped and carry `null`. Drives which - * channel-scoped enforcements (delete/kick) are offerable. - */ - channelId: string | null; - /** Reports about this target, newest first. */ - reports: ModerationReport[]; - /** Highest severity among the group's reports — drives group ordering. */ - maxSeverity: number; - /** Most recent report timestamp in the group (ISO), for tie-breaks. */ - latestCreatedAt: string; - /** Prior accepted actions already taken against this target (newest first). */ - priorActions: ModerationAction[]; -}; - -/** Newest-first ISO timestamp comparator (descending). */ -function byCreatedAtDesc( - a: { createdAt: string }, - b: { createdAt: string }, -): number { - return b.createdAt.localeCompare(a.createdAt); -} - -/** - * Does an audit row concern the same target as a queue group? Reports point at - * events, pubkeys, or blobs; audit rows carry `targetPubkey` / `targetEventId` - * (blobs are not separately keyed in the audit shape, so blob groups surface no - * prior-actions correlation — by design, not omission). - */ -function actionMatchesTarget( - action: ModerationAction, - targetKind: ReportTargetKind, - target: string, -): boolean { - if (targetKind === "event") return action.targetEventId === target; - if (targetKind === "pubkey") return action.targetPubkey === target; - return false; -} - -/** - * Build the triaged queue: reports grouped by target, each group carrying its - * max severity, prior actions, and reports newest-first; groups sorted by - * severity desc, then most-recent-report desc. `actions` is the audit log used - * to attach prior-actions context (pass `[]` when unavailable). - */ -export function buildModerationQueue( - reports: readonly ModerationReport[], - actions: readonly ModerationAction[] = [], -): ModerationQueueGroup[] { - const groups = new Map(); - - for (const report of reports) { - const key = targetKey(report); - const existing = groups.get(key); - if (existing) { - existing.reports.push(report); - existing.maxSeverity = Math.max( - existing.maxSeverity, - reportSeverity(report.reportType), - ); - } else { - groups.set(key, { - targetKey: key, - targetKind: report.targetKind, - target: report.target, - channelId: report.channelId, - reports: [report], - maxSeverity: reportSeverity(report.reportType), - latestCreatedAt: report.createdAt, - priorActions: [], - }); - } - } - - for (const group of groups.values()) { - group.reports.sort(byCreatedAtDesc); - group.latestCreatedAt = - group.reports[0]?.createdAt ?? group.latestCreatedAt; - group.priorActions = actions - .filter((a) => actionMatchesTarget(a, group.targetKind, group.target)) - .sort(byCreatedAtDesc); - } - - return [...groups.values()].sort((a, b) => { - if (b.maxSeverity !== a.maxSeverity) return b.maxSeverity - a.maxSeverity; - return b.latestCreatedAt.localeCompare(a.latestCreatedAt); - }); -} - -/** Reports still awaiting a decision (`status === "open"`). */ -export function isOpenReport(report: ModerationReport): boolean { - return report.status === "open"; -} - -/** Human label for a NIP-56 report category. */ -export function reportTypeLabel(reportType: ReportType): string { - switch (reportType) { - case "illegal": - return "Illegal content"; - case "nudity": - return "Nudity"; - case "malware": - return "Malware"; - case "spam": - return "Spam"; - case "impersonation": - return "Impersonation"; - case "profanity": - return "Profanity"; - case "other": - return "Other"; - } -} - -/** - * Coarse severity tier for badge styling. `illegal` is `critical` (escalation - * lane); malware/impersonation are `high`; the rest are `normal`. Kept separate - * from the numeric `reportSeverity` rank so the visual tiers can be tuned - * without perturbing sort order. - */ -export type SeverityTier = "critical" | "high" | "normal"; - -export function severityTier(reportType: ReportType): SeverityTier { - if (reportType === "illegal") return "critical"; - if (reportType === "malware" || reportType === "impersonation") return "high"; - return "normal"; -} - -/** The most severe report type in a group (drives the group's badge). */ -export function groupTopReportType(group: ModerationQueueGroup): ReportType { - let top = group.reports[0]?.reportType ?? "other"; - for (const report of group.reports) { - if (reportSeverity(report.reportType) > reportSeverity(top)) { - top = report.reportType; - } - } - return top; -} - -/** - * Which one-click resolutions can actually be *enforced* for a given target. - * - * A 9044 resolve only records the decision + DMs the reporter; the client must - * compose the paired enforcement event (delete→9005, ban→9040, kick→9001). - * Some pairings are structurally impossible, so we never offer them as buttons - * (Eva's ruling: an action you can't complete shouldn't be clickable): - * - * - `delete` (9005) needs an event id + channel — only event-target reports. - * - `kick` (9001) is channel-scoped — needs both an author and a channel, so - * only event-target reports (a pubkey report is not tied to a channel). - * - `ban` (9040) needs only the author pubkey — event reports resolve it from - * the reported event's signer; pubkey reports carry it as the target. - * - `escalate` / `dismiss` are decision-only and always available. - * - * `timeout` is intentionally excluded until the resolve flow can collect a - * duration (a duration-less timeout would be a lie); it wires back in with the - * duration picker as a follow-up. - */ -export function resolvableActions( - targetKind: ReportTargetKind, - hasChannel: boolean, -): ResolutionAction[] { - const actions: ResolutionAction[] = []; - if (targetKind === "event" && hasChannel) actions.push("delete"); - // ban needs only the author; event reports look it up from the signer. - if (targetKind === "event" || targetKind === "pubkey") actions.push("ban"); - if (targetKind === "event" && hasChannel) actions.push("kick"); - actions.push("escalate", "dismiss"); - return actions; -} diff --git a/desktop/src/features/settings/ui/ModerationQueueCard.tsx b/desktop/src/features/settings/ui/ModerationQueueCard.tsx deleted file mode 100644 index de414bbf0d1..00000000000 --- a/desktop/src/features/settings/ui/ModerationQueueCard.tsx +++ /dev/null @@ -1,596 +0,0 @@ -import { AlertTriangle, ChevronDown, ShieldAlert } from "lucide-react"; -import { useMemo } from "react"; -import { toast } from "sonner"; - -import { - useModerationAuditQuery, - useModerationReportsQuery, - useResolveReportMutation, - useBanMemberMutation, - type ModerationReport as HookModerationReport, - type ResolutionAction, -} from "@/features/moderation/hooks"; -import { useMyRelayMembershipQuery } from "@/features/community-members/hooks"; -import { useUsersBatchQuery } from "@/features/profile/hooks"; -import { - deleteMessage, - getEventById, - removeChannelMember, -} from "@/shared/api/tauri"; -import { - buildModerationQueue, - groupTopReportType, - reportTypeLabel, - resolvableActions, - severityTier, - type ModerationAction, - type ModerationQueueGroup, - type ModerationReport, - type ReportStatus, - type ReportType, - type SeverityTier, -} from "@/features/settings/lib/moderationQueue"; -import { cn } from "@/shared/lib/cn"; -import { truncatePubkey } from "@/shared/lib/pubkey"; -import { Button } from "@/shared/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/shared/ui/dropdown-menu"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; -import { SettingsSectionHeader } from "./SettingsSectionHeader"; - -// The queue is mod-only: only relay owners/admins may read /moderation/* (the -// relay returns 403 otherwise). Mirror that gate client-side so members never -// see the panel attempt a doomed fetch. - -// --- Boundary normalizer -------------------------------------------------- -// -// The shared hooks expose the wire rows; this card's triage math lives in -// `lib/moderationQueue.ts`, which reuses the shared row shapes but narrows -// `reportType`/`status` to precise unions. Report rows need that narrowing cast -// at the boundary; audit rows are structurally identical (ModerationAction = -// the shared shape), so they flow through untouched. - -function toQueueReport(r: HookModerationReport): ModerationReport { - return { - ...r, - reportType: r.reportType as ReportType, - status: r.status as ReportStatus, - }; -} - -/** Stable empty-array reference so audit-derived memos don't churn on refetch. */ -const EMPTY_ACTIONS: readonly ModerationAction[] = []; - -// --- Resolution vocabulary ------------------------------------------------ -// -// The relay pairs `dismiss` with status `dismissed` and every other action -// with `resolved` (moderation_commands.rs: `(action == "dismiss") == -// (status == "dismissed")`). Encode that pairing here so the UI can never -// submit an invalid combination. -function statusForAction(action: ResolutionAction): "resolved" | "dismissed" { - return action === "dismiss" ? "dismissed" : "resolved"; -} - -/** - * Resolve the author (signer) pubkey a member-directed enforcement acts on. - * For a pubkey-target report that IS the target; for an event-target report the - * report row carries only the event id (the reporter's `p` author tag is - * dropped at ingest), so we read the reported event and take its signer — the - * stored `pubkey` is signer truth, never a `p`/`actor` override. Throws if the - * event can't be resolved (e.g. already deleted) so the caller aborts before - * touching the 9044. - */ -async function resolveTargetAuthor( - group: ModerationQueueGroup, -): Promise { - if (group.targetKind === "pubkey") return group.target; - const event = await getEventById(group.target); - if (!event?.pubkey) { - throw new Error("Could not resolve the message author."); - } - return event.pubkey; -} - -/** - * Compose the enforcement event paired with a resolution, BEFORE the 9044. - * - * A 9044 resolve records the decision and DMs the reporter "reviewed and acted - * on" — so it must not fire until the action actually happened. Enforce first; - * on success the caller sends the 9044. On failure this throws and the caller - * leaves the report open (no false DM, no orphan decision row). `escalate` and - * `dismiss` carry no enforcement — they are pure 9044 decisions. - */ -async function enforceResolution( - group: ModerationQueueGroup, - action: ResolutionAction, - ban: (input: { pubkey: string; reason?: string }) => Promise, -): Promise { - switch (action) { - case "delete": - // Gated to event targets with a channel (resolvableActions). - if (group.channelId == null) throw new Error("Report has no channel."); - await deleteMessage(group.channelId, group.target); - return; - case "ban": - await ban({ pubkey: await resolveTargetAuthor(group) }); - return; - case "kick": - // Gated to event targets with a channel (resolvableActions). - if (group.channelId == null) throw new Error("Report has no channel."); - await removeChannelMember( - group.channelId, - await resolveTargetAuthor(group), - ); - return; - case "escalate": - case "dismiss": - return; - case "timeout": - // Dropped from one-click until the resolve flow collects a duration. - throw new Error("Timeout is not available from the queue yet."); - } -} - -const RESOLUTION_OPTIONS: { - action: ResolutionAction; - label: string; - description: string; -}[] = [ - { - action: "delete", - label: "Delete content", - description: "Remove the reported content and resolve.", - }, - { - action: "kick", - label: "Kick author", - description: "Remove the author from the community.", - }, - { - action: "ban", - label: "Ban author", - description: "Block the author from the community.", - }, - { - action: "timeout", - label: "Time out author", - description: "Temporarily mute the author.", - }, - { - action: "escalate", - label: "Escalate", - description: "Route to the platform-safety lane.", - }, - { - action: "dismiss", - label: "Dismiss", - description: "No violation — close without action.", - }, -]; - -function formatTimestamp(iso: string): string { - const date = new Date(iso); - if (Number.isNaN(date.getTime())) return iso; - return date.toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }); -} - -const SEVERITY_BADGE: Record = { - critical: "bg-destructive/15 text-destructive", - high: "bg-amber-500/15 text-amber-600 dark:text-amber-400", - normal: "bg-muted text-muted-foreground", -}; - -function targetLabel(group: ModerationQueueGroup): string { - const short = truncatePubkey(group.target); - switch (group.targetKind) { - case "event": - return `Message ${short}`; - case "pubkey": - return `Member ${short}`; - case "blob": - return `Attachment ${short}`; - } -} - -function ReporterLine({ - report, - displayName, -}: { - report: ModerationReport; - displayName?: string | null; -}) { - const who = displayName?.trim() || truncatePubkey(report.reporterPubkey); - return ( -
        -
        - - {reportTypeLabel(report.reportType)} - - - reported by {who} · {formatTimestamp(report.createdAt)} - -
        - {report.note ? ( -

        - {report.note} -

        - ) : null} -
        - ); -} - -function ResolveMenu({ - allowed, - disabled, - onResolve, -}: { - allowed: readonly ResolutionAction[]; - disabled: boolean; - onResolve: (action: ResolutionAction) => void; -}) { - const options = RESOLUTION_OPTIONS.filter((option) => - allowed.includes(option.action), - ); - return ( - - - - - - Resolution - - {options.map((option) => ( - onResolve(option.action)} - > -
        - {option.label} - - {option.description} - -
        -
        - ))} -
        -
        - ); -} - -function QueueGroupCard({ - group, - reporterNames, - onResolve, - disabled, -}: { - group: ModerationQueueGroup; - reporterNames: Record; - onResolve: (group: ModerationQueueGroup, action: ResolutionAction) => void; - disabled: boolean; -}) { - const topType = groupTopReportType(group); - const tier = severityTier(topType); - return ( -
        -
        -
        -
        - - {tier === "critical" ? ( - - ) : null} - {reportTypeLabel(topType)} - - - {targetLabel(group)} - - - · {group.reports.length}{" "} - {group.reports.length === 1 ? "report" : "reports"} - -
        -
        -
        - onResolve(group, action)} - /> -
        -
        - -
        - {group.reports.map((report) => ( - - ))} -
        - - {group.priorActions.length > 0 ? ( -
        - - - {group.priorActions.length} prior action - {group.priorActions.length === 1 ? "" : "s"} against this target - {" — "} - {group.priorActions - .slice(0, 3) - .map((a) => a.action) - .join(", ")} - -
        - ) : null} -
        - ); -} - -function QueueTab() { - const reportsQuery = useModerationReportsQuery({ status: "open" }); - const auditQuery = useModerationAuditQuery(); - const resolveMutation = useResolveReportMutation(); - const banMutation = useBanMemberMutation(); - - const groups = useMemo(() => { - const reports = (reportsQuery.data ?? []).map(toQueueReport); - return buildModerationQueue(reports, auditQuery.data ?? []); - }, [reportsQuery.data, auditQuery.data]); - - const reporterPubkeys = useMemo( - () => - groups.flatMap((group) => - group.reports.map((report) => report.reporterPubkey), - ), - [groups], - ); - const reporterProfiles = useUsersBatchQuery(reporterPubkeys, { - enabled: reporterPubkeys.length > 0, - }); - const reporterNames = useMemo(() => { - const map: Record = {}; - const profiles = reporterProfiles.data?.profiles ?? {}; - for (const [pubkey, summary] of Object.entries(profiles)) { - map[pubkey.toLowerCase()] = summary?.displayName ?? null; - } - return map; - }, [reporterProfiles.data]); - - async function handleResolve( - group: ModerationQueueGroup, - action: ResolutionAction, - ) { - const status = statusForAction(action); - const openReports = group.reports.filter( - (report) => report.status === "open", - ); - try { - // Enforce FIRST. The 9044 resolve DMs the reporter "reviewed and acted - // on" — if enforcement fails we must not send that lie, and we leave the - // report open (retryable, no orphan decision row). Only after the paired - // 9040/9005/9001 lands do we resolve every open report about this target. - await enforceResolution(group, action, banMutation.mutateAsync); - await Promise.all( - openReports.map((report) => - resolveMutation.mutateAsync({ - reportEventId: report.reportEventId, - status, - action, - }), - ), - ); - toast.success( - status === "dismissed" ? "Report dismissed" : "Report resolved", - ); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "Failed to resolve the report", - ); - } - } - - if (reportsQuery.error instanceof Error) { - return ( -

        - {reportsQuery.error.message} -

        - ); - } - if (reportsQuery.isLoading) { - return

        Loading reports…

        ; - } - if (groups.length === 0) { - return ( -

        - No open reports. The queue is clear. -

        - ); - } - return ( -
        - {groups.map((group) => ( - - ))} -
        - ); -} - -function AuditRow({ - action, - actorName, -}: { - action: ModerationAction; - actorName?: string | null; -}) { - const who = actorName?.trim() || truncatePubkey(action.actorPubkey); - const targetShort = action.targetPubkey - ? truncatePubkey(action.targetPubkey) - : action.targetEventId - ? truncatePubkey(action.targetEventId) - : null; - return ( -
        -
        - - {action.action.replace(/_/g, " ")} - - {targetShort ? ( - - → {targetShort} - - ) : null} - - by {who} · {formatTimestamp(action.createdAt)} - -
        - {action.publicReason ? ( -

        - {action.publicReason} -

        - ) : null} -
        - ); -} - -function AuditTab() { - const auditQuery = useModerationAuditQuery(); - - const actions = auditQuery.data ?? EMPTY_ACTIONS; - - const actorPubkeys = useMemo( - () => actions.map((action) => action.actorPubkey), - [actions], - ); - const actorProfiles = useUsersBatchQuery(actorPubkeys, { - enabled: actorPubkeys.length > 0, - }); - const actorNames = useMemo(() => { - const map: Record = {}; - const profiles = actorProfiles.data?.profiles ?? {}; - for (const [pubkey, summary] of Object.entries(profiles)) { - map[pubkey.toLowerCase()] = summary?.displayName ?? null; - } - return map; - }, [actorProfiles.data]); - - if (auditQuery.error instanceof Error) { - return ( -

        - {auditQuery.error.message} -

        - ); - } - if (auditQuery.isLoading) { - return

        Loading audit log…

        ; - } - if (actions.length === 0) { - return ( -

        - No moderation actions yet. -

        - ); - } - return ( -
        - {actions.map((action) => ( - - ))} -
        - ); -} - -export function ModerationQueueCard() { - const membershipQuery = useMyRelayMembershipQuery(); - const role = membershipQuery.data?.role; - const isModerator = role === "owner" || role === "admin"; - - return ( -
        - - - {!isModerator ? ( - membershipQuery.isLoading ? ( -

        Checking access…

        - ) : ( -

        - The moderation queue is available to community moderators only. -

        - ) - ) : ( - - - - Queue - - - Audit log - - - - - - - - - - )} -
        - ); -} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 1dda5bc1c0c..4a50de54817 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -65,10 +65,10 @@ import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard"; import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard"; import { MeshComputeSettingsCard } from "@/features/mesh-compute/ui/MeshComputeSettingsCard"; import { MobilePairingCard } from "./MobilePairingCard"; -import { ModerationQueueCard } from "./ModerationQueueCard"; import { NotificationSettingsCard } from "./NotificationSettingsCard"; import { AgentsSettingsPanel } from "./AgentsSettingsPanel"; import { HostedCommunitiesSettingsCard } from "./HostedCommunitiesSettingsCard"; +import { AdminConsoleSettingsCard } from "@/features/admin-console/AdminConsoleSettingsCard"; import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; import { ProfileSettingsCard } from "./ProfileSettingsCard"; import { UpdateChecker } from "../UpdateChecker"; @@ -862,7 +862,7 @@ export function renderSettingsSection( ); case "moderation": - return ; + return ; case "custom-emoji": return ; case "local-archive": diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index 2f9b2c36a1a..b3ce0585b9b 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -3,6 +3,8 @@ import { getVersion } from "@tauri-apps/api/app"; import { AlertCircle, ArrowLeft, LoaderCircle, RefreshCw } from "lucide-react"; import { useMyRelayMembershipLookupQuery } from "@/features/community-members/hooks"; +import { useModerationNavResolution } from "@/features/admin-console/hooks"; +import { shouldShowModerationNav } from "@/features/admin-console/nav"; import { canManageCommunityMembers, shouldWarnMissingMembershipSnapshot, @@ -48,7 +50,7 @@ type SettingsViewProps = SettingsPanelProps & { section: SettingsSection; }; -const settingsNavGroups: Array<{ +export const settingsNavGroups: Array<{ label: string; sections: SettingsSection[]; }> = [ @@ -67,7 +69,7 @@ const settingsNavGroups: Array<{ }, { label: "Communities", - sections: ["hosted-communities", "community-members"], + sections: ["hosted-communities", "community-members", "moderation"], }, { label: "App", @@ -129,6 +131,7 @@ export function SettingsView({ }: SettingsViewProps) { const { isMobile, open: sidebarOpen, setOpen: setSidebarOpen } = useSidebar(); const myMembershipQuery = useMyRelayMembershipLookupQuery(); + const moderationNav = useModerationNavResolution(); const featureState = useFeatureSnapshot(); const visibleSections = React.useMemo(() => { return settingsSections.filter((s) => { @@ -146,9 +149,14 @@ export function SettingsView({ if (s.value === "community-members") { return canManageCommunityMembers(myMembershipQuery.data); } + // Moderation surfaces the relay admin console. Hidden until the origin + // resolves (no flash of a dead entry); then gated by origin + probe. + if (s.value === "moderation") { + return moderationNav != null && shouldShowModerationNav(moderationNav); + } return true; }); - }, [myMembershipQuery.data, featureState]); + }, [myMembershipQuery.data, moderationNav, featureState]); const [isLoaded, setIsLoaded] = React.useState(false); const [appVersion, setAppVersion] = React.useState(null); @@ -163,10 +171,18 @@ export function SettingsView({ }, []); React.useEffect(() => { + // Defer section normalization while a direct link (`?section=moderation`) + // targets the Moderation entry and its visibility is still unresolved. + // That entry's visibility depends on an async origin+probe resolver + // (`moderationNav` is `undefined` until it resolves); redirecting first + // would bounce a valid direct link before the probe can authorize. + if (section === "moderation" && moderationNav === undefined) { + return; + } if (!visibleSections.some((entry) => entry.value === section)) { onSectionChange(visibleSections[0]?.value ?? "appearance"); } - }, [onSectionChange, section, visibleSections]); + }, [moderationNav, onSectionChange, section, visibleSections]); React.useEffect(() => { if (!isMobile && !sidebarOpen) { diff --git a/desktop/src/features/settings/ui/settingsNavGroups.test.mjs b/desktop/src/features/settings/ui/settingsNavGroups.test.mjs new file mode 100644 index 00000000000..1a25adffcd3 --- /dev/null +++ b/desktop/src/features/settings/ui/settingsNavGroups.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { settingsNavGroups } from "./SettingsView.tsx"; + +test("moderation is wired into the Communities nav group", () => { + const communitiesGroup = settingsNavGroups.find( + (g) => g.label === "Communities", + ); + assert.ok( + communitiesGroup, + "Communities group must exist in settingsNavGroups", + ); + assert.ok( + communitiesGroup.sections.includes("moderation"), + `expected "moderation" in Communities group sections, got: ${JSON.stringify(communitiesGroup.sections)}`, + ); +}); + +test("moderation follows community-members in the Communities nav group", () => { + const communitiesGroup = settingsNavGroups.find( + (g) => g.label === "Communities", + ); + assert.ok( + communitiesGroup, + "Communities group must exist in settingsNavGroups", + ); + const membersIndex = communitiesGroup.sections.indexOf("community-members"); + const moderationIndex = communitiesGroup.sections.indexOf("moderation"); + assert.ok(membersIndex !== -1, "community-members must be present"); + assert.ok( + moderationIndex > membersIndex, + `expected "moderation" after "community-members", got: ${JSON.stringify(communitiesGroup.sections)}`, + ); +}); + +test("the removed admin-console id is not wired into any nav group", () => { + for (const group of settingsNavGroups) { + assert.ok( + !group.sections.includes("admin-console"), + `"admin-console" must not appear in the "${group.label}" group`, + ); + } +}); diff --git a/desktop/test-jsdom-setup.mjs b/desktop/test-jsdom-setup.mjs new file mode 100644 index 00000000000..52217dc16df --- /dev/null +++ b/desktop/test-jsdom-setup.mjs @@ -0,0 +1,15 @@ +// Install jsdom globals before any test module (including React) is evaluated. +// This ensures React's canUseDOM = true so isInputEventSupported is set correctly. +import { JSDOM } from "jsdom"; +const dom = new JSDOM("", { url: "http://localhost" }); +const jsdomWindow = dom.window; +globalThis.window = jsdomWindow; +globalThis.document = jsdomWindow.document; +for (const key of Object.getOwnPropertyNames(jsdomWindow)) { + if (!(key in globalThis)) { + try { + globalThis[key] = jsdomWindow[key]; + } catch {} + } +} +globalThis.IS_REACT_ACT_ENVIRONMENT = true; diff --git a/scripts/seed-admin-dashboard.sh b/scripts/seed-admin-dashboard.sh index e9ff10644ed..4d42600f720 100755 --- a/scripts/seed-admin-dashboard.sh +++ b/scripts/seed-admin-dashboard.sh @@ -128,27 +128,43 @@ BEGIN RAISE EXCEPTION 'local community is missing; run just setup first'; END IF; + -- A real channel for the failed-enforcement report below. Kick is only valid + -- on `event` reports, and the relay rejects it pre-mutation unless the report + -- carries a channel_id (FK into channels). Seeding this channel makes the + -- Kick action reachable in the UI and lets the enforcement genuinely run and + -- fail, so the Cancel & reopen recovery path is exercisable locally. + INSERT INTO channels (community_id, id, name, created_by) + VALUES ( + local_community_id, + 'c4a11e10-0000-4000-8000-000000000001', + 'seed-enforcement-channel', + decode(repeat('3b', 32), 'hex') + ) + ON CONFLICT (community_id, id) DO UPDATE SET name = EXCLUDED.name; + INSERT INTO moderation_reports ( community_id, id, report_event_id, reporter_pubkey, target_kind, - target_event_id, target_pubkey, target_blob_sha256, report_type, note, + target_event_id, target_pubkey, target_blob_sha256, channel_id, report_type, note, status, resolved_by, resolved_at, created_at ) VALUES - (local_community_id, 'a11d0000-0000-4000-8000-000000000001', decode(repeat('01', 32), 'hex'), decode(repeat('11', 32), 'hex'), 'event', decode(repeat('21', 32), 'hex'), NULL, NULL, 'spam', 'Repeated unsolicited promotion across several channels.', 'open', NULL, NULL, now() - interval '8 minutes'), - (local_community_id, 'a11d0000-0000-4000-8000-000000000002', decode(repeat('02', 32), 'hex'), decode(repeat('12', 32), 'hex'), 'pubkey', NULL, decode(repeat('22', 32), 'hex'), NULL, 'impersonation', 'Profile appears to impersonate a community organizer.', 'open', NULL, NULL, now() - interval '25 minutes'), - (local_community_id, 'a11d0000-0000-4000-8000-000000000003', decode(repeat('03', 32), 'hex'), decode(repeat('13', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('23', 32), 'hex'), 'malware', 'Attachment was flagged after download.', 'open', NULL, NULL, now() - interval '50 minutes'), - (local_community_id, 'a11d0000-0000-4000-8000-000000000004', decode(repeat('04', 32), 'hex'), decode(repeat('14', 32), 'hex'), 'event', decode(repeat('24', 32), 'hex'), NULL, NULL, 'illegal', 'Contains material that may require legal review.', 'open', NULL, NULL, now() - interval '2 hours'), - (local_community_id, 'a11d0000-0000-4000-8000-000000000005', decode(repeat('05', 32), 'hex'), decode(repeat('15', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('25', 32), 'hex'), 'nudity', NULL, 'open', NULL, NULL, now() - interval '5 hours'), - (local_community_id, 'a11d0000-0000-4000-8000-000000000006', decode(repeat('06', 32), 'hex'), decode(repeat('16', 32), 'hex'), 'pubkey', NULL, decode(repeat('26', 32), 'hex'), NULL, 'profanity', 'Repeated abusive replies from this account.', 'open', NULL, NULL, now() - interval '12 hours'), - (local_community_id, 'a11d0000-0000-4000-8000-000000000007', decode(repeat('07', 32), 'hex'), decode(repeat('17', 32), 'hex'), 'event', decode(repeat('27', 32), 'hex'), NULL, NULL, 'other', 'Does not fit a standard report category.', 'open', NULL, NULL, now() - interval '1 day'), - (local_community_id, 'a11d0000-0000-4000-8000-000000000008', decode(repeat('08', 32), 'hex'), decode(repeat('18', 32), 'hex'), 'pubkey', NULL, decode(repeat('28', 32), 'hex'), NULL, 'impersonation', 'Escalated while ownership is verified.', 'escalated', decode(repeat('38', 32), 'hex'), now() - interval '1 hour', now() - interval '2 days'), - (local_community_id, 'a11d0000-0000-4000-8000-000000000009', decode(repeat('09', 32), 'hex'), decode(repeat('19', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('29', 32), 'hex'), 'malware', 'Resolved after the attachment was removed.', 'resolved', decode(repeat('39', 32), 'hex'), now() - interval '1 day', now() - interval '3 days'), - (local_community_id, 'a11d0000-0000-4000-8000-000000000010', decode(repeat('0a', 32), 'hex'), decode(repeat('1a', 32), 'hex'), 'event', decode(repeat('2a', 32), 'hex'), NULL, NULL, 'other', 'Dismissed after reviewing the surrounding thread.', 'dismissed', decode(repeat('3a', 32), 'hex'), now() - interval '3 days', now() - interval '4 days') + (local_community_id, 'a11d0000-0000-4000-8000-000000000001', decode(repeat('01', 32), 'hex'), decode(repeat('11', 32), 'hex'), 'event', decode(repeat('21', 32), 'hex'), NULL, NULL, NULL, 'spam', 'Repeated unsolicited promotion across several channels.', 'open', NULL, NULL, now() - interval '8 minutes'), + (local_community_id, 'a11d0000-0000-4000-8000-000000000002', decode(repeat('02', 32), 'hex'), decode(repeat('12', 32), 'hex'), 'pubkey', NULL, decode(repeat('22', 32), 'hex'), NULL, NULL, 'impersonation', 'Profile appears to impersonate a community organizer.', 'open', NULL, NULL, now() - interval '25 minutes'), + (local_community_id, 'a11d0000-0000-4000-8000-000000000003', decode(repeat('03', 32), 'hex'), decode(repeat('13', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('23', 32), 'hex'), NULL, 'malware', 'Attachment was flagged after download.', 'open', NULL, NULL, now() - interval '50 minutes'), + (local_community_id, 'a11d0000-0000-4000-8000-000000000004', decode(repeat('04', 32), 'hex'), decode(repeat('14', 32), 'hex'), 'event', decode(repeat('24', 32), 'hex'), NULL, NULL, NULL, 'illegal', 'Contains material that may require legal review.', 'open', NULL, NULL, now() - interval '2 hours'), + (local_community_id, 'a11d0000-0000-4000-8000-000000000005', decode(repeat('05', 32), 'hex'), decode(repeat('15', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('25', 32), 'hex'), NULL, 'nudity', NULL, 'open', NULL, NULL, now() - interval '5 hours'), + (local_community_id, 'a11d0000-0000-4000-8000-000000000006', decode(repeat('06', 32), 'hex'), decode(repeat('16', 32), 'hex'), 'pubkey', NULL, decode(repeat('26', 32), 'hex'), NULL, NULL, 'profanity', 'Repeated abusive replies from this account.', 'open', NULL, NULL, now() - interval '12 hours'), + (local_community_id, 'a11d0000-0000-4000-8000-000000000007', decode(repeat('07', 32), 'hex'), decode(repeat('17', 32), 'hex'), 'event', decode(repeat('27', 32), 'hex'), NULL, NULL, NULL, 'other', 'Does not fit a standard report category.', 'open', NULL, NULL, now() - interval '1 day'), + (local_community_id, 'a11d0000-0000-4000-8000-000000000008', decode(repeat('08', 32), 'hex'), decode(repeat('18', 32), 'hex'), 'pubkey', NULL, decode(repeat('28', 32), 'hex'), NULL, NULL, 'impersonation', 'Escalated while ownership is verified.', 'escalated', decode(repeat('38', 32), 'hex'), now() - interval '1 hour', now() - interval '2 days'), + (local_community_id, 'a11d0000-0000-4000-8000-000000000009', decode(repeat('09', 32), 'hex'), decode(repeat('19', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('29', 32), 'hex'), NULL, 'malware', 'Resolved after the attachment was removed.', 'resolved', decode(repeat('39', 32), 'hex'), now() - interval '1 day', now() - interval '3 days'), + (local_community_id, 'a11d0000-0000-4000-8000-000000000010', decode(repeat('0a', 32), 'hex'), decode(repeat('1a', 32), 'hex'), 'event', decode(repeat('2a', 32), 'hex'), NULL, NULL, NULL, 'other', 'Dismissed after reviewing the surrounding thread.', 'dismissed', decode(repeat('3a', 32), 'hex'), now() - interval '3 days', now() - interval '4 days'), + (local_community_id, 'a11d0000-0000-4000-8000-000000000011', decode(repeat('0b', 32), 'hex'), decode(repeat('1b', 32), 'hex'), 'event', decode(repeat('2b', 32), 'hex'), NULL, NULL, 'c4a11e10-0000-4000-8000-000000000001', 'spam', 'Event report in a real channel — Kick is offered and the enforcement genuinely runs and fails, exercising the Cancel & reopen recovery path.', 'open', NULL, NULL, now() - interval '3 minutes') ON CONFLICT (community_id, report_event_id) DO UPDATE SET reporter_pubkey = EXCLUDED.reporter_pubkey, target_kind = EXCLUDED.target_kind, target_event_id = EXCLUDED.target_event_id, target_pubkey = EXCLUDED.target_pubkey, target_blob_sha256 = EXCLUDED.target_blob_sha256, + channel_id = EXCLUDED.channel_id, report_type = EXCLUDED.report_type, note = EXCLUDED.note, status = EXCLUDED.status, @@ -191,4 +207,4 @@ sql="${sql//__WORKSPACE_DIAGNOSTICS_SIZE__/$(fixture_size "${workspace_diagnosti run_psql -v ON_ERROR_STOP=1 -c "${sql}" -echo "Seeded 10 moderation reports, 7 feedback entries, and 5 attachments for the local admin dashboard." +echo "Seeded 11 moderation reports, 7 feedback entries, and 5 attachments for the local admin dashboard."