From e56c80d61d473f6fa7859d6baf753ae2d2090447 Mon Sep 17 00:00:00 2001 From: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 14:42:45 -0400 Subject: [PATCH 01/10] feat(desktop): add in-app admin console for relay operators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a NIP-98 client for the /api/admin/v1 relay API, surfaced as a new 'Admin console' section in Settings. Relay operators can view deployment-wide moderation reports and product feedback from within the Buzz desktop app — no browser extension or bearer token required. Rust (Phase 1): - AdminOrigin value object: validates scheme+host+optional-port, rejects credentials/path/query/fragment; http:// only for loopback hosts - AdminRoute closed enum: five routes (reports list, report detail, feedback list, feedback detail, feedback attachment); no IPC surface accepts arbitrary URLs or paths; signed URL == fetched URL - Dedicated no-redirect reqwest client singleton (SSRF guard: relay 3xx surfaced as error, NIP-98 header not forwarded across origins) - Six Tauri commands: admin_probe, admin_list_reports, admin_get_report, admin_list_feedback, admin_get_feedback, admin_fetch_feedback_attachment - Two storage commands: get_admin_origin / set_admin_origin (per-pubkey JSON file in app_data_dir, atomic write, 0o600) - NIP-98 signing via AppState::signing_keys() — returns Err in recovery mode (locked keyring); exactly one retry on 401 with a fresh event - Response bounds: 50 MiB JSON cap (200-row report list × 256 KiB notes), 64 KiB error cap, 10 MiB attachment cap; enforced by Content-Length preflight AND streaming byte counter - Attachment command: caller supplies expected MIME/size from imeta- validated feedback detail; native layer validates Content-Type and byte count, returns body-only tauri::ipc::Response; stable typed error codes - admin_probe: 6-state typed enum (Nip98Authorized/Denied, TokenMode, Disabled, NotAdminApi, NetworkOrIntercepted); Nip98Authorized only on authenticated 2xx; never a Bearer fallback - Host-case pin test documents that url::Url lowercases ASCII hostnames — operators must configure BUZZ_ADMIN_HOST in lowercase TypeScript (Phase 2): - desktop/src/features/admin-console/api.ts: typed wrappers for all 8 Tauri commands; attachment returns Blob URL from expectedMime (never a response header); blob revocation on caller - AdminConsoleSettingsCard: URL input field, save/probe flow, per-pubkey state, honest copy for every probe state (denied shows copyable hex pubkey, tokenMode points at web console, networkOrIntercepted names VPN/SSO interception) - AdminConsolePanel: tab bar (Reports / Feedback), list/detail views, AttachmentViewer with blob URL lifecycle and typed error messages - SettingsPanels: adds 'admin-console' section type, descriptor (Server icon), and render case; exhaustive switch maintained - Probe state keyed by (active pubkey, canonical origin); in-flight probes cancelled on change; object URLs revoked on unmount Docs (Phase 3): - docs/admin/README.md: Desktop app section with setup steps, probe state table, and the Cloudflare Access caveat verbatim from the plan - Authentication modes table added - CHANGELOG.md: Unreleased entry Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- CHANGELOG.md | 17 +- .../src-tauri/src/commands/admin/client.rs | 178 +++++ desktop/src-tauri/src/commands/admin/mod.rs | 644 ++++++++++++++++++ .../src-tauri/src/commands/admin/origin.rs | 270 ++++++++ .../src-tauri/src/commands/admin/routes.rs | 151 ++++ desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/lib.rs | 13 + .../admin-console/AdminConsolePanel.tsx | 478 +++++++++++++ .../AdminConsoleSettingsCard.tsx | 312 +++++++++ desktop/src/features/admin-console/api.ts | 161 +++++ .../features/settings/ui/SettingsPanels.tsx | 13 +- docs/admin/README.md | 69 ++ 12 files changed, 2297 insertions(+), 11 deletions(-) create mode 100644 desktop/src-tauri/src/commands/admin/client.rs create mode 100644 desktop/src-tauri/src/commands/admin/mod.rs create mode 100644 desktop/src-tauri/src/commands/admin/origin.rs create mode 100644 desktop/src-tauri/src/commands/admin/routes.rs create mode 100644 desktop/src/features/admin-console/AdminConsolePanel.tsx create mode 100644 desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx create mode 100644 desktop/src/features/admin-console/api.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 171f260d3e..9d98f16d3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,15 @@ # Changelog +## Unreleased + +### Desktop and shared changes + +- feat(desktop): add in-app admin console for relay operators — NIP-98 client for deployment-wide reports and product feedback (`Settings → Admin console`) + ## v0.5.5 ### Desktop and shared changes -- feat: paste composer text without formatting ([#4801](https://github.com/block/buzz/pull/4801)) ([`25a9cf1be6d245fbd7373cb1160dbc790baf5bd5`](https://github.com/block/buzz/commit/25a9cf1be6d245fbd7373cb1160dbc790baf5bd5)) -- Revert "chore(release): release Buzz Desktop version 0.5.5" ([#4808](https://github.com/block/buzz/pull/4808)) ([`79c52166cfe6b6d36bdc7686f943595c74e2f578`](https://github.com/block/buzz/commit/79c52166cfe6b6d36bdc7686f943595c74e2f578)) -- chore(release): release Buzz Desktop version 0.5.5 ([#4800](https://github.com/block/buzz/pull/4800)) ([`a0ed13de14ee64dd90c32335790f7d3b4e94330d`](https://github.com/block/buzz/commit/a0ed13de14ee64dd90c32335790f7d3b4e94330d)) -- fix: reauthenticate databricks model discovery ([#4008](https://github.com/block/buzz/pull/4008)) ([`4a2305170eef565bf1836e2859247e67c030f8af`](https://github.com/block/buzz/commit/4a2305170eef565bf1836e2859247e67c030f8af)) -- Revert "chore(release): release Buzz Desktop version 0.5.5" ([#4797](https://github.com/block/buzz/pull/4797)) ([`8faf09f9aedb4989e57c7b6c5bd1052a444a3370`](https://github.com/block/buzz/commit/8faf09f9aedb4989e57c7b6c5bd1052a444a3370)) -- feat: Buzz entity links — rich preview cards + in-app navigation for repos, PRs, and issues ([#4695](https://github.com/block/buzz/pull/4695)) ([`a1d78f2959b41c63f063ff818076d38c31071a47`](https://github.com/block/buzz/commit/a1d78f2959b41c63f063ff818076d38c31071a47)) -- fix(desktop): serialize tray channel actions for frontend ([#4762](https://github.com/block/buzz/pull/4762)) ([`4c665aeac366fca5097eaa1088fb87f3d248eac7`](https://github.com/block/buzz/commit/4c665aeac366fca5097eaa1088fb87f3d248eac7)) -- chore(release): release Buzz Desktop version 0.5.5 ([#4788](https://github.com/block/buzz/pull/4788)) ([`b948c54792c4933b4e003d2b227dc6e1f7c05fb4`](https://github.com/block/buzz/commit/b948c54792c4933b4e003d2b227dc6e1f7c05fb4)) -- feat(projects): support multiple repositories ([#4671](https://github.com/block/buzz/pull/4671)) ([`e30db7028f9f1dc7646b5814ed03b4c54a4d2a48`](https://github.com/block/buzz/commit/e30db7028f9f1dc7646b5814ed03b4c54a4d2a48)) - fix(desktop): widen post-Enter timeouts in empty-edit-delete spec ([#4792](https://github.com/block/buzz/pull/4792)) ([`7bcfe7e0a141900d6e1e5bd0b3bce488b57d6453`](https://github.com/block/buzz/commit/7bcfe7e0a141900d6e1e5bd0b3bce488b57d6453)) - fix(desktop): wait for terminal frame before splash ([#4781](https://github.com/block/buzz/pull/4781)) ([`65f7a100353b9a5302da2614f2d85edee1c136a2`](https://github.com/block/buzz/commit/65f7a100353b9a5302da2614f2d85edee1c136a2)) - fix(desktop): integer-align custom reaction emoji ([#4779](https://github.com/block/buzz/pull/4779)) ([`8b8d86c5d26e2fa8cf419fdd8d0e56433f95d71a`](https://github.com/block/buzz/commit/8b8d86c5d26e2fa8cf419fdd8d0e56433f95d71a)) @@ -53,7 +50,7 @@ - fix(mobile): recover stale relay sessions ([#4372](https://github.com/block/buzz/pull/4372)) ([`ce56e34411d2940e70a6c0de653ffae36d334701`](https://github.com/block/buzz/commit/ce56e34411d2940e70a6c0de653ffae36d334701)) [Compare desktop-v0.5.4...desktop-v0.5.5](https://github.com/block/buzz/compare/desktop-v0.5.4...desktop-v0.5.5) - +>>>>>>> 9570237a1 (feat(desktop): add in-app admin console for relay operators) ## v0.5.4 ### Desktop and shared changes 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 0000000000..1f037dac4b --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/client.rs @@ -0,0 +1,178 @@ +//! 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. +const ADMIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +/// Error from an admin HTTP operation. Kept as an enum so tests can assert on +/// the classification without parsing error strings. +#[derive(Debug)] +pub enum AdminFetchError { + /// The request could not be sent or the connection failed. + Network(String), + /// The server returned a 3xx redirect (not followed). + Redirect(u16), + /// The server returned a non-2xx, non-redirect status. + Status(u16, String), + /// The response body exceeded the cap. + TooLarge, +} + +impl std::fmt::Display for AdminFetchError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AdminFetchError::Network(e) => write!(f, "admin network error: {e}"), + AdminFetchError::Redirect(code) => { + write!(f, "admin API returned a {code} redirect (not followed)") + } + AdminFetchError::Status(code, body) => { + write!(f, "admin API returned {code}: {body}") + } + AdminFetchError::TooLarge => write!(f, "admin response exceeded size cap"), + } + } +} + +/// Fetch raw bytes from `url` (GET, no auth) with a streaming cap. +/// +/// Exported for unit tests; production callers use the command functions which +/// add NIP-98 authentication and retry logic. +pub async fn admin_fetch_bytes_raw(url: &str, cap: u64) -> Result, AdminFetchError> { + use futures_util::StreamExt; + + let client = ADMIN_CLIENT + .get() + .ok_or_else(|| AdminFetchError::Network("admin client not initialised".to_string()))?; + let resp = client + .get(url) + .timeout(ADMIN_TIMEOUT) + .send() + .await + .map_err(|e| AdminFetchError::Network(e.to_string()))?; + + if resp.status().is_redirection() { + return Err(AdminFetchError::Redirect(resp.status().as_u16())); + } + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp + .text() + .await + .unwrap_or_else(|_| "".to_string()); + return Err(AdminFetchError::Status(status, body)); + } + + if let Some(cl) = resp.content_length() { + if cl > cap { + return Err(AdminFetchError::TooLarge); + } + } + + 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| AdminFetchError::Network(e.to_string()))?; + if bytes.len() as u64 + chunk.len() as u64 > cap { + return Err(AdminFetchError::TooLarge); + } + bytes.extend_from_slice(&chunk); + } + + Ok(bytes) +} + +/// 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/mod.rs b/desktop/src-tauri/src/commands/admin/mod.rs new file mode 100644 index 0000000000..f448eb74ad --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/mod.rs @@ -0,0 +1,644 @@ +//! 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(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 + +/// 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 + +// ── 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. + Nip98Authorized, + /// NIP-98 mode is active but the app keypair was rejected after a signed + /// attempt. Likely: pubkey not in `BUZZ_ADMIN_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 ───────────────────────────────────────────────────────────────── + +/// 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/reports?limit=1`. +/// 2. 200 → `Disabled` (admin accessible without a credential). +/// 3. 401 + `WWW-Authenticate: Nostr` → NIP-98 mode. Retry with a freshly +/// signed kind-27235. 200 → `Nip98Authorized`; non-200 → `Nip98Denied`. +/// 4. 401 + `WWW-Authenticate: Bearer` → `TokenMode`. +/// 5. 403/404 or other non-401 → `NotAdminApi`. +/// 6. 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; + + let origin = origin::AdminOrigin::parse(&origin)?; + let url = origin.route_url( + &routes::AdminRoute::ReportsList, + &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); + } + }; + + // Step 2: success without auth → disabled mode. + if resp.status().is_success() { + return Ok(AdminProbeResult::Disabled); + } + + // Step 3–5: 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 keys = match state.signing_keys() { + Ok(k) => k, + Err(_) => return Ok(AdminProbeResult::Nip98Denied), + }; + let auth_header = + build_nip98_auth_header_for_keys(&keys, &reqwest::Method::GET, &url, &[]) + .map_err(|e| format!("nip98 build failed: {e}"))?; + let auth_resp = match http_client + .get(&url) + .header(reqwest::header::AUTHORIZATION, &auth_header) + .send() + .await + { + Ok(r) => r, + Err(_) => return Ok(AdminProbeResult::NetworkOrIntercepted), + }; + return if auth_resp.status().is_success() { + Ok(AdminProbeResult::Nip98Authorized) + } else { + Ok(AdminProbeResult::Nip98Denied) + }; + } + + if www_auth.starts_with("bearer") { + return Ok(AdminProbeResult::TokenMode); + } + + // Unknown 401 shape. + return Ok(AdminProbeResult::NotAdminApi); + } + + if resp.status().is_redirection() { + return Ok(AdminProbeResult::NetworkOrIntercepted); + } + + Ok(AdminProbeResult::NotAdminApi) +} + +// ── 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 url = origin.route_url( + &routes::AdminRoute::ReportDetail { id: &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 url = origin.route_url( + &routes::AdminRoute::FeedbackDetail { id: &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 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. + if sha256.len() != 64 || !sha256.chars().all(|c| c.is_ascii_hexdigit()) { + return 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: &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 ─────────────────────────────────────────────── + +/// Return the persisted admin console origin for the active pubkey, or `None` +/// if none has been saved yet. +#[tauri::command] +pub fn get_admin_origin( + app: tauri::AppHandle, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result, String> { + let pubkey = state + .signing_keys() + .map(|k| k.public_key().to_hex()) + .unwrap_or_default(); + let path = admin_origin_path(&app, &pubkey)?; + 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 = serde_json::from_str(&content) + .map_err(|e| format!("failed to parse admin console origin: {e}"))?; + Ok(Some(stored.origin)) +} + +/// Validate and persist the admin console origin for the active pubkey. +/// +/// 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, + app: tauri::AppHandle, + state: tauri::State<'_, crate::app_state::AppState>, +) -> Result, String> { + use crate::managed_agents::storage::atomic_write_json_restricted; + + let pubkey = state + .signing_keys() + .map(|k| k.public_key().to_hex()) + .unwrap_or_default(); + let path = admin_origin_path(&app, &pubkey)?; + + match raw_origin { + None => { + // Clear. + 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)) + } + } +} + +/// On-disk shape for the persisted admin console origin. +#[derive(serde::Serialize, serde::Deserialize)] +struct StoredAdminOrigin { + origin: String, +} + +/// Path of the per-pubkey admin-console-origin JSON file. +fn admin_origin_path( + app: &tauri::AppHandle, + pubkey_hex: &str, +) -> Result { + 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}"))?; + Ok(dir.join(format!("admin-console-origin-{pubkey_hex}.json"))) +} + +// ── Internal helpers ────────────────────────────────────────────────────── + +/// Fetch a JSON endpoint with NIP-98 auth, one 401-retry, and a size cap. +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 +} + +/// Stream and validate an attachment response, enforcing Content-Type, size, +/// and the cap. +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). +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) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::admin::{origin::AdminOrigin, routes::AdminRoute}; + + // ── 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 ───────────────────────────────────────── + // + // These are pure-logic tests that do not require a live Tauri state. + + fn valid_hash() -> String { + "a".repeat(64) + } + + #[test] + fn attachment_hash_must_be_64_hex_chars() { + // Valid: 64 lowercase hex chars. + let h = valid_hash(); + assert_eq!(h.len(), 64); + assert!(h.chars().all(|c| c.is_ascii_hexdigit())); + + // Invalid: 63 chars (too short). + let short: String = "a".repeat(63); + assert_ne!(short.len(), 64); + + // Invalid: non-hex character ('g' is not a hex digit). + let non_hex: String = "g".repeat(64); + assert!(non_hex.chars().any(|c| !c.is_ascii_hexdigit())); + + // Note: uppercase A-F ARE valid hex digits per is_ascii_hexdigit(). + // The production guard rejects them only if is_ascii_hexdigit() returns + // false. Uppercase input like "AAAA...AAAA" (64 chars) would pass the + // length+hexdigit check — callers must normalise to lowercase if needed. + let upper_hex: String = "A".repeat(64); + assert_eq!(upper_hex.len(), 64); + assert!(upper_hex.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn attachment_size_zero_is_invalid() { + assert_eq!(0u64, 0); + // Production guard: expected_size == 0 yields admin_attachment_invalid_size. + } + + #[test] + fn attachment_over_cap_is_invalid() { + let over_cap = ATTACHMENT_CAP + 1; + assert!(over_cap > ATTACHMENT_CAP); + // Production guard: expected_size > ATTACHMENT_CAP yields admin_attachment_too_large. + } + + // ── Content-Type matching logic ─────────────────────────────────────────── + + #[test] + fn content_type_matching_is_case_insensitive_and_strips_params() { + // finish_attachment_response normalises content_type with .to_ascii_lowercase() + // and splits on ';' to strip charset params. + let raw = "Image/PNG; charset=binary"; + let normalised = raw.split(';').next().unwrap().trim().to_ascii_lowercase(); + assert_eq!(normalised, "image/png"); + // This would match expected_mime "image/png". + assert_eq!(normalised, "image/png".trim().to_ascii_lowercase()); + } +} 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 0000000000..1d09044296 --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/origin.rs @@ -0,0 +1,270 @@ +//! `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::() + .map_or(false, |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 = "abc-123"; + 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/abc-123" + ); + } + + #[test] + fn route_url_feedback_attachment() { + let o = AdminOrigin::parse("https://admin.example.com").unwrap(); + let url = o.route_url( + &AdminRoute::FeedbackAttachment { + id: "fb-id", + sha256: "abcdef01".repeat(8).as_str(), + }, + &AdminQuery::default(), + ); + assert!(url.contains("/api/admin/v1/feedback/fb-id/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 0000000000..0eea171f0e --- /dev/null +++ b/desktop/src-tauri/src/commands/admin/routes.rs @@ -0,0 +1,151 @@ +//! 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. + +/// The five read routes exposed by `/api/admin/v1`. +/// +/// Named `'_` lifetime on borrowed id/sha256 fields so callers can pass +/// `&str` slices without allocating. +#[derive(Debug)] +pub enum AdminRoute<'a> { + ReportsList, + ReportDetail { id: &'a str }, + FeedbackList, + FeedbackDetail { id: &'a str }, + FeedbackAttachment { id: &'a str, sha256: &'a str }, +} + +impl<'a> AdminRoute<'a> { + /// Return the URL path component (not including the `/api/admin/v1` prefix). + pub fn path(&self) -> String { + match self { + AdminRoute::ReportsList => "/reports".to_string(), + AdminRoute::ReportDetail { id } => format!("/reports/{id}"), + AdminRoute::FeedbackList => "/feedback".to_string(), + AdminRoute::FeedbackDetail { id } => format!("/feedback/{id}"), + AdminRoute::FeedbackAttachment { id, sha256 } => { + format!("/feedback/{id}/attachments/{sha256}") + } + } + } +} + +/// 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::*; + + #[test] + fn reports_list_path() { + assert_eq!(AdminRoute::ReportsList.path(), "/reports"); + } + + #[test] + fn report_detail_path() { + assert_eq!( + AdminRoute::ReportDetail { id: "abc-123" }.path(), + "/reports/abc-123" + ); + } + + #[test] + fn feedback_attachment_path() { + let hash = "a".repeat(64); + assert_eq!( + AdminRoute::FeedbackAttachment { + id: "fb-id", + sha256: &hash + } + .path(), + format!("/feedback/fb-id/attachments/{hash}") + ); + } + + #[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 237bc06e8d..e0a99a5a68 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_auth; mod agent_config; mod agent_discovery; @@ -63,6 +64,7 @@ mod window_vibrancy; mod workflows; mod workspace; +pub use admin::*; pub use agent_auth::*; pub use agent_config::*; pub use agent_discovery::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index a7c191c43b..91e9c3068d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -311,6 +311,10 @@ pub fn run() { #[cfg(target_os = "macos")] tray_menu::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. @@ -901,6 +905,15 @@ 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, + get_admin_origin, + set_admin_origin, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/desktop/src/features/admin-console/AdminConsolePanel.tsx b/desktop/src/features/admin-console/AdminConsolePanel.tsx new file mode 100644 index 0000000000..c1ab288286 --- /dev/null +++ b/desktop/src/features/admin-console/AdminConsolePanel.tsx @@ -0,0 +1,478 @@ +/** + * Main admin console panel — renders when probe state is `nip98Authorized`. + * + * Shows two tabs: Reports (deployment-wide moderation reports) and Feedback + * (product feedback with optional image attachments). + * + * All query/UI state is keyed by `(origin)`, which is already scoped to the + * active pubkey by the settings card (pubkey changed → different origin stored). + * In-flight requests are cancelled on origin change via useEffect cleanup. + */ + +import { useEffect, useRef, useState } from "react"; +import { + AlertCircle, + ChevronLeft, + Download, + LoaderCircle, + MessageSquare, + ShieldAlert, +} from "lucide-react"; +import { Button } from "@/shared/ui/button"; +import { cn } from "@/shared/lib/cn"; +import { + fetchAdminAttachmentBlobUrl, + getAdminFeedback, + getAdminReport, + listAdminFeedback, + listAdminReports, + type AdminAttachmentErrorCode, +} from "./api"; + +// ── Generic async state ─────────────────────────────────────────────────── + +type AsyncState = + | { status: "idle" } + | { status: "loading" } + | { status: "ok"; data: T } + | { status: "error"; message: string }; + +function useAsyncLoad( + load: (signal: AbortSignal) => Promise, + deps: unknown[], +): AsyncState { + const [state, setState] = useState>({ status: "idle" }); + + useEffect(() => { + const controller = new AbortController(); + setState({ status: "loading" }); + load(controller.signal).then( + (data) => { + if (!controller.signal.aborted) setState({ status: "ok", data }); + }, + (e: unknown) => { + if (!controller.signal.aborted) { + setState({ + status: "error", + message: e instanceof Error ? e.message : String(e), + }); + } + }, + ); + return () => controller.abort(); + // biome-ignore lint/correctness/useExhaustiveDependencies: deps array is passed from the call site as the explicit trigger list; adding `load` would cause infinite re-runs since it's created inline + }, deps); + + return state; +} + +// ── Reports tab ─────────────────────────────────────────────────────────── + +function ReportsTab({ origin }: { origin: string }) { + const [selectedId, setSelectedId] = useState(null); + + const listState = useAsyncLoad(() => listAdminReports(origin), [origin]); + + if (selectedId) { + return ( + setSelectedId(null)} + /> + ); + } + + if (listState.status === "loading") { + return ; + } + if (listState.status === "error") { + return ; + } + if (listState.status !== "ok") return null; + + const reports = listState.data as Array>; + if (!Array.isArray(reports) || reports.length === 0) { + return

No reports found.

; + } + + return ( +
    + {reports.map((report) => { + const id = String(report.id ?? report.reportId ?? ""); + const summary = + String(report.summary ?? report.report_type ?? "") || "Report"; + const status = String(report.status ?? ""); + return ( +
  • + +
  • + ); + })} +
+ ); +} + +function ReportDetail({ + origin, + reportId, + onBack, +}: { + origin: string; + reportId: string; + onBack: () => void; +}) { + const detailState = useAsyncLoad( + () => getAdminReport(origin, reportId), + [origin, reportId], + ); + + return ( +
+ + {detailState.status === "loading" && } + {detailState.status === "error" && ( + + )} + {detailState.status === "ok" && ( +
+          {JSON.stringify(detailState.data, null, 2)}
+        
+ )} +
+ ); +} + +// ── Feedback tab ────────────────────────────────────────────────────────── + +function FeedbackTab({ origin }: { origin: string }) { + const [selectedId, setSelectedId] = useState(null); + + const listState = useAsyncLoad(() => listAdminFeedback(origin), [origin]); + + if (selectedId) { + return ( + setSelectedId(null)} + origin={origin} + /> + ); + } + + if (listState.status === "loading") return ; + if (listState.status === "error") { + return ; + } + if (listState.status !== "ok") return null; + + const items = listState.data as Array>; + if (!Array.isArray(items) || items.length === 0) { + return

No feedback found.

; + } + + return ( +
    + {items.map((item) => { + const id = String(item.id ?? item.feedbackId ?? ""); + const text = String( + item.feedback ?? item.message ?? item.content ?? "", + ).slice(0, 120); + const createdAt = String(item.created_at ?? item.createdAt ?? ""); + return ( +
  • + +
  • + ); + })} +
+ ); +} + +// ── Attachment viewer ───────────────────────────────────────────────────── + +type AttachmentMeta = { + sha256: string; + mime: string; + size: number; +}; + +function AttachmentViewer({ + origin, + feedbackId, + attachment, +}: { + origin: string; + feedbackId: string; + attachment: AttachmentMeta; +}) { + const [blobUrl, setBlobUrl] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const blobUrlRef = useRef(null); + + // Revoke blob URL on unmount. + useEffect(() => { + return () => { + if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current); + }; + }, []); + + async function load() { + setLoading(true); + setError(null); + try { + const url = await fetchAdminAttachmentBlobUrl( + origin, + feedbackId, + attachment.sha256, + attachment.mime, + attachment.size, + ); + // Revoke any previous blob before replacing. + if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = url; + setBlobUrl(url); + } catch (e) { + setError( + typeof e === "string" ? (e as AdminAttachmentErrorCode) : String(e), + ); + } finally { + setLoading(false); + } + } + + 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) { + return ( + + ); + } + + if (attachment.mime.startsWith("image/")) { + return ( + Feedback attachment + ); + } + + return ( + + + Download attachment ({attachment.mime}) + + ); +} + +function FeedbackDetail({ + origin, + feedbackId, + onBack, +}: { + origin: string; + feedbackId: string; + onBack: () => void; +}) { + const detailState = useAsyncLoad( + () => getAdminFeedback(origin, feedbackId), + [origin, feedbackId], + ); + + // Extract imeta attachment metadata from the detail. + const attachments: AttachmentMeta[] = []; + if (detailState.status === "ok") { + const detail = detailState.data as Record; + const rawAttachments = detail.attachments; + if (Array.isArray(rawAttachments)) { + for (const a of rawAttachments as Array>) { + const sha256 = String(a.sha256 ?? a.hash ?? ""); + const mime = String(a.mime ?? a.m ?? a.content_type ?? ""); + const size = Number(a.size ?? a.content_length ?? 0); + if (sha256.length === 64 && mime && size > 0) { + attachments.push({ sha256, mime, size }); + } + } + } + } + + return ( +
+ + {detailState.status === "loading" && } + {detailState.status === "error" && ( + + )} + {detailState.status === "ok" && ( + <> +
+            {JSON.stringify(detailState.data, null, 2)}
+          
+ {attachments.length > 0 && ( +
+

Attachments

+ {attachments.map((a) => ( + + ))} +
+ )} + + )} +
+ ); +} + +// ── Tab bar ─────────────────────────────────────────────────────────────── + +type Tab = "reports" | "feedback"; + +function TabBar({ + activeTab, + onSelect, +}: { + activeTab: Tab; + onSelect: (tab: Tab) => void; +}) { + return ( +
+ {( + [ + { value: "reports" as const, label: "Reports", Icon: ShieldAlert }, + { + value: "feedback" as const, + label: "Feedback", + Icon: MessageSquare, + }, + ] as const + ).map(({ value, label, Icon }) => ( + + ))} +
+ ); +} + +// ── Shared helpers ──────────────────────────────────────────────────────── + +function LoadingSpinner() { + return ( +
+ + Loading… +
+ ); +} + +function ErrorMessage({ message }: { message: string }) { + return ( +
+ + {message} +
+ ); +} + +// ── Panel root ──────────────────────────────────────────────────────────── + +export function AdminConsolePanel({ origin }: { origin: string }) { + const [activeTab, setActiveTab] = useState("reports"); + + return ( +
+ + {activeTab === "reports" && } + {activeTab === "feedback" && } +
+ ); +} diff --git a/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx b/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx new file mode 100644 index 0000000000..925c9fba0d --- /dev/null +++ b/desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx @@ -0,0 +1,312 @@ +/** + * 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. + * + * Renders the full admin panel only when probe state is `nip98Authorized`. + * All other states surface honest, actionable copy without false hope. + */ + +import { useEffect, useRef, useState } from "react"; +import { AlertCircle, CheckCircle2, 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 { + getAdminOrigin, + probeAdminOrigin, + setAdminOrigin, + type AdminProbeState, +} from "./api"; +import { AdminConsolePanel } from "./AdminConsolePanel"; +import { useIdentityQuery } from "@/shared/api/hooks"; + +// ── Probe state → UI copy ───────────────────────────────────────────────── + +type ProbeUiState = + | { kind: "idle" } + | { kind: "probing" } + | { kind: "authorized"; origin: string } + | { 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 ( + + + + Access denied + + + Your pubkey is not in{" "} + BUZZ_ADMIN_PUBKEYS. Ask your relay + operator to add: + + + {uiState.pubkeyHex} + + + Other possible causes: clock skew > 60 s, relay config mismatch, or + the relay is running{" "} + BUZZ_ADMIN_AUTH=token instead of{" "} + nip98. + + + ); + } + 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( + state: AdminProbeState, + origin: string, + pubkeyHex: string, +): ProbeUiState { + switch (state) { + case "nip98Authorized": + return { kind: "authorized", origin }; + 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 ?? ""; + + const [originInput, setOriginInput] = useState(""); + const [savedOrigin, setSavedOrigin] = useState(null); + const [probeUiState, setProbeUiState] = useState({ + kind: "idle", + }); + const [isSaving, setIsSaving] = useState(false); + + // Cancel probe if pubkey or origin changes mid-flight. + const probeAbortRef = useRef(null); + + // Load saved origin on mount and when pubkey changes. + // biome-ignore lint/correctness/useExhaustiveDependencies: runProbe is defined inline and recreated each render; pubkeyHex is the only intentional reset trigger + useEffect(() => { + if (!pubkeyHex) return; + let cancelled = false; + void (async () => { + try { + const saved = await getAdminOrigin(); + if (cancelled) return; + setSavedOrigin(saved); + setOriginInput(saved ?? ""); + if (saved) { + runProbe(saved, pubkeyHex); + } + } catch { + // Ignore — settings card degrades gracefully. + } + })(); + return () => { + cancelled = true; + }; + }, [pubkeyHex]); + + function runProbe(origin: string, pkHex: string) { + // Cancel any in-flight probe. + 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.state, origin, pkHex)); + } 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(); + setIsSaving(true); + try { + if (!trimmed) { + const canonical = await setAdminOrigin(null); + setSavedOrigin(canonical); + setProbeUiState({ kind: "idle" }); + return; + } + const canonical = await setAdminOrigin(trimmed); + setSavedOrigin(canonical); + if (canonical) { + runProbe(canonical, pubkeyHex); + } else { + setProbeUiState({ kind: "idle" }); + } + } catch (e) { + setProbeUiState({ + kind: "error", + message: e instanceof Error ? e.message : String(e), + }); + } finally { + setIsSaving(false); + } + } + + const inputChanged = originInput.trim() !== (savedOrigin ?? ""); + const isAuthorized = + probeUiState.kind === "authorized" && savedOrigin !== null; + + return ( +
+ + +
+
+ setOriginInput(e.target.value)} + placeholder="https://admin.yourrelay.example.com" + spellCheck={false} + type="url" + value={originInput} + onKeyDown={(e) => { + if (e.key === "Enter") void handleSave(); + }} + /> + + {savedOrigin && ( + + )} +
+ +
+ +
+
+ + {isAuthorized && savedOrigin && ( + + )} +
+ ); +} diff --git a/desktop/src/features/admin-console/api.ts b/desktop/src/features/admin-console/api.ts new file mode 100644 index 0000000000..674239567e --- /dev/null +++ b/desktop/src/features/admin-console/api.ts @@ -0,0 +1,161 @@ +/** + * 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"; + +export type AdminProbeResult = { state: AdminProbeState }; + +/** + * 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. + */ +export async function getAdminOrigin(): Promise { + return invokeTauri("get_admin_origin", {}); +} + +/** + * 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. + */ +export async function setAdminOrigin( + rawOrigin: string | null, +): Promise { + return invokeTauri("set_admin_origin", { + rawOrigin, + }); +} + +// ── 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 }); +} + +// ── 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/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 162150e7c6..50c9855657 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -14,6 +14,7 @@ import { MessagesSquare, MonitorCog, Moon, + Server, ShieldAlert, Smartphone, Smile, @@ -85,6 +86,7 @@ import { ProfileSettingsCard } from "./ProfileSettingsCard"; import { UpdateChecker } from "../UpdateChecker"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; import { VoiceSettingsCard } from "./VoiceSettingsCard"; +import { AdminConsoleSettingsCard } from "@/features/admin-console/AdminConsoleSettingsCard"; export type SettingsSection = | "profile" @@ -102,7 +104,8 @@ export type SettingsSection = | "custom-emoji" | "local-archive" | "mobile" - | "updates"; + | "updates" + | "admin-console"; export const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile"; @@ -123,6 +126,7 @@ const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [ "local-archive", "mobile", "updates", + "admin-console", ]; export function isSettingsSection(value: unknown): value is SettingsSection { @@ -239,6 +243,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [ label: "Updates", icon: Download, }, + { + value: "admin-console", + label: "Admin console", + icon: Server, + }, ]; function formatThemeLabel(name: string): string { @@ -852,6 +861,8 @@ export function renderSettingsSection( return ; case "updates": return ; + case "admin-console": + return ; default: { const exhaustiveCheck: never = section; return exhaustiveCheck; diff --git a/docs/admin/README.md b/docs/admin/README.md index e51566fb29..057feb4307 100644 --- a/docs/admin/README.md +++ b/docs/admin/README.md @@ -67,3 +67,72 @@ admission is not per-operator identity. Anyone admitted to the dashboard can read attachments for feedback records they can access. Per-person attribution or revocation requires authenticated operator identity at ingress/application level; this endpoint deliberately does not claim to provide it. + +## Authentication modes + +Set `BUZZ_ADMIN_AUTH` to one of: + +| Value | Behaviour | +|-------|-----------| +| `nip98` | Requires a `Authorization: Nostr ` signed with a key listed in `BUZZ_ADMIN_PUBKEYS` | +| `token` | Requires `Authorization: Bearer ` where the token equals `BUZZ_ADMIN_TOKEN` | +| `disabled` | No credential required (development use only) | + +The default is `nip98`. + +## Desktop app + +The Buzz desktop app ships a built-in admin console client. It does not require +a browser extension, separate web UI, or bearer token. It uses NIP-98 +authentication (mode `nip98` only). + +### Setup + +1. In your relay config, set `BUZZ_ADMIN_AUTH=nip98`. +2. Add your identity pubkey to `BUZZ_ADMIN_PUBKEYS`: + - Open **Settings → Admin console** in the Buzz desktop app. + - Copy the hex pubkey shown in the denied-access message, or find it under + **Settings → Profile**. + - Paste it into your relay's `BUZZ_ADMIN_PUBKEYS` environment variable. +3. In **Settings → Admin console**, paste the value of `BUZZ_ADMIN_HOST` (e.g. + `https://admin.yourrelay.example.com`) into the admin console URL field. + - The URL must be an origin only (scheme + host + optional port). No path, + query string, or fragment. + - The host must match `BUZZ_ADMIN_HOST` exactly, including case. The relay + compares byte-for-byte. Use lowercase. + - `http://` is accepted only for `localhost`, `127.x.x.x`, and `[::1]`; all + other hosts require `https://`. +4. Click **Save**. The app probes the origin and shows **Connected** when the + current identity is on the allowlist. + +### How it works + +The desktop client signs a NIP-98 kind-27235 event for each request URL and +method using the app's own keypair (the same identity used for messaging). It +does not require a separate admin key. + +The client uses a dedicated no-redirect HTTP client. A relay-issued redirect is +surfaced as an error rather than followed, so the `Authorization` header is +never forwarded to a different host. Every request URL is constructed natively +from a closed route enum — the webview cannot supply arbitrary paths. + +Response sizes are bounded: JSON responses are capped at 50 MiB (sized for a +200-row report list where each note field may reach the 256 KiB event-content +limit). Attachment previews are capped at 10 MiB. + +### Probe states + +| State | Meaning | +|-------|---------| +| **Connected** | NIP-98 mode, current identity authorised | +| **Access denied** | NIP-98 mode, identity not in `BUZZ_ADMIN_PUBKEYS`; or clock skew > 60 s | +| **Bearer-token mode** | Relay requires a bearer token. Use the web console — the desktop app supports NIP-98 only | +| **Auth disabled** | `BUZZ_ADMIN_AUTH=disabled`. Accessible without a credential | +| **No admin API** | Origin reachable but `/api/admin/v1` not found. Check the URL matches `BUZZ_ADMIN_HOST` | +| **Network/intercepted** | Network or TLS error, DNS failure, or an SSO/VPN layer (e.g. Cloudflare Access) intercepting the host | + +### Deployment note + +> **The desktop client is NOT advertised as usable against `admin.buzz.xyz` until +> the Cloudflare Access carve-out follow-up resolves (separate arc).** Self-hosted +> deployments without Cloudflare Access interception are unaffected. From 19227ee35dd1c576bbbe219ca8c59ae9bc87367d Mon Sep 17 00:00:00 2001 From: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 15:37:17 -0400 Subject: [PATCH 02/10] fix(admin): address pass-1 review findings on desktop admin surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 1 (CRITICAL): Parse the real tags:string[][] imeta wire contract instead of speculative aliases. Implements parseImetaAttachments() matching the reference SPA — selects imeta tags, splits singleton key-value entries, requires lowercase 64-hex x and positive size. Corrects camelCase field names throughout (reportType, bodySummary, body, receivedAt). Fix 2 (CRITICAL): Storage fail-closed. Both get/set_admin_origin now propagate signing_keys()? instead of unwrap_or_default(), preventing recovery-mode collapse onto a shared admin-console-origin-.json file. Adds validate_pubkey_hex() guard requiring exactly 64 lowercase hex chars. Fix 3 (IMPORTANT): Parse report/feedback IDs as uuid::Uuid before building any route, making path injection via slash, .., ?, #, or percent-escapes structurally impossible. AttachmentHash::parse() now enforces lowercase-hex [0-9a-f]{64} — rejects uppercase (relay returns 404 on uppercase). Adds 11 adversarial tests. Fix 4 (IMPORTANT): Harden admin_probe. Bounds every probe body read. Validates the unauthenticated 200 response as a JSON array before returning Disabled. Detects HTML/Cloudflare Access interception via is_probe_response_intercepted() (checks final URL host and Content-Type). Adds 8 live-listener async tests including HTML 200, malformed-JSON 200, Nostr 401 → authenticated JSON 200 (stub validates Authorization header shape), and persistent 401. Fix 5 (IMPORTANT): Generation guards in TS. Replaces AbortController with a generation counter keyed on (pubkey, origin). AdminConsolePanel takes a required pubkey prop; generation increments on any (pubkey, origin) change. useAsyncLoad captures generation at call time via a ref-based load pattern. AttachmentViewer has its own per-load generation counter and checks (origin, pubkey) before committing blob URLs. Blob URLs are revoked when panelGeneration changes. AdminConsoleSettingsCard threads pubkey={pubkeyHex}. Fix 6 (IMPORTANT): Revalidate persisted origin on read. get_admin_origin now reparses the stored value through AdminOrigin::parse(), returns the canonical form, and removes the file + returns an error if the stored value is invalid or non-canonical. Fix 7 (MINOR): Remove dead code — delete AdminFetchError enum and admin_fetch_bytes_raw (never called by production paths). Fix clippy nit: .map_or(false, |ip| ip.is_loopback()) → .is_ok_and(...). Fix doc default: BUZZ_ADMIN_AUTH defaults to `token`, not `nip98`. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src-tauri/src/commands/admin/client.rs | 80 +--- desktop/src-tauri/src/commands/admin/mod.rs | 423 +++++++++++++++--- .../src-tauri/src/commands/admin/origin.rs | 19 +- .../src-tauri/src/commands/admin/routes.rs | 148 +++++- .../admin-console/AdminConsolePanel.tsx | 273 ++++++++--- .../AdminConsoleSettingsCard.tsx | 2 +- docs/admin/README.md | 2 +- 7 files changed, 713 insertions(+), 234 deletions(-) diff --git a/desktop/src-tauri/src/commands/admin/client.rs b/desktop/src-tauri/src/commands/admin/client.rs index 1f037dac4b..21e805776a 100644 --- a/desktop/src-tauri/src/commands/admin/client.rs +++ b/desktop/src-tauri/src/commands/admin/client.rs @@ -9,85 +9,7 @@ use std::sync::OnceLock; /// Request timeout for admin API calls. -const ADMIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); - -/// Error from an admin HTTP operation. Kept as an enum so tests can assert on -/// the classification without parsing error strings. -#[derive(Debug)] -pub enum AdminFetchError { - /// The request could not be sent or the connection failed. - Network(String), - /// The server returned a 3xx redirect (not followed). - Redirect(u16), - /// The server returned a non-2xx, non-redirect status. - Status(u16, String), - /// The response body exceeded the cap. - TooLarge, -} - -impl std::fmt::Display for AdminFetchError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - AdminFetchError::Network(e) => write!(f, "admin network error: {e}"), - AdminFetchError::Redirect(code) => { - write!(f, "admin API returned a {code} redirect (not followed)") - } - AdminFetchError::Status(code, body) => { - write!(f, "admin API returned {code}: {body}") - } - AdminFetchError::TooLarge => write!(f, "admin response exceeded size cap"), - } - } -} - -/// Fetch raw bytes from `url` (GET, no auth) with a streaming cap. -/// -/// Exported for unit tests; production callers use the command functions which -/// add NIP-98 authentication and retry logic. -pub async fn admin_fetch_bytes_raw(url: &str, cap: u64) -> Result, AdminFetchError> { - use futures_util::StreamExt; - - let client = ADMIN_CLIENT - .get() - .ok_or_else(|| AdminFetchError::Network("admin client not initialised".to_string()))?; - let resp = client - .get(url) - .timeout(ADMIN_TIMEOUT) - .send() - .await - .map_err(|e| AdminFetchError::Network(e.to_string()))?; - - if resp.status().is_redirection() { - return Err(AdminFetchError::Redirect(resp.status().as_u16())); - } - - if !resp.status().is_success() { - let status = resp.status().as_u16(); - let body = resp - .text() - .await - .unwrap_or_else(|_| "".to_string()); - return Err(AdminFetchError::Status(status, body)); - } - - if let Some(cl) = resp.content_length() { - if cl > cap { - return Err(AdminFetchError::TooLarge); - } - } - - 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| AdminFetchError::Network(e.to_string()))?; - if bytes.len() as u64 + chunk.len() as u64 > cap { - return Err(AdminFetchError::TooLarge); - } - bytes.extend_from_slice(&chunk); - } - - Ok(bytes) -} +pub(crate) const ADMIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); /// The module-level singleton admin HTTP client. /// diff --git a/desktop/src-tauri/src/commands/admin/mod.rs b/desktop/src-tauri/src/commands/admin/mod.rs index f448eb74ad..7d9d23abed 100644 --- a/desktop/src-tauri/src/commands/admin/mod.rs +++ b/desktop/src-tauri/src/commands/admin/mod.rs @@ -89,12 +89,15 @@ pub struct AdminReportsQuery { /// /// Algorithm: /// 1. Send an unauthenticated GET to `/api/admin/v1/reports?limit=1`. -/// 2. 200 → `Disabled` (admin accessible without a credential). -/// 3. 401 + `WWW-Authenticate: Nostr` → NIP-98 mode. Retry with a freshly -/// signed kind-27235. 200 → `Nip98Authorized`; non-200 → `Nip98Denied`. -/// 4. 401 + `WWW-Authenticate: Bearer` → `TokenMode`. -/// 5. 403/404 or other non-401 → `NotAdminApi`. -/// 6. Network/redirect/TLS error → `NetworkOrIntercepted`. +/// 2. Detect HTML/interception pages (Cloudflare Access, captive portals) +/// from Content-Type and final URL host → `NetworkOrIntercepted`. +/// 3. 200 + valid JSON list shape → `Disabled` (admin accessible without cred). +/// 4. 401 + `WWW-Authenticate: Nostr` → NIP-98 mode. Retry with a freshly +/// signed kind-27235. 200 + valid list shape → `Nip98Authorized`; +/// 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, @@ -105,7 +108,10 @@ pub async fn admin_probe( let origin = origin::AdminOrigin::parse(&origin)?; let url = origin.route_url( &routes::AdminRoute::ReportsList, - &routes::AdminQuery::default(), + &routes::AdminQuery { + limit: Some(1), + ..Default::default() + }, ); let http_client = client::ADMIN_CLIENT @@ -121,12 +127,26 @@ pub async fn admin_probe( } }; - // Step 2: success without auth → disabled mode. + 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 (if body is a valid list). if resp.status().is_success() { - return Ok(AdminProbeResult::Disabled); + let bytes = read_bounded(resp, SUCCESS_JSON_CAP).await?; + return if looks_like_admin_list(&bytes) { + Ok(AdminProbeResult::Disabled) + } else { + Ok(AdminProbeResult::NotAdminApi) + }; } - // Step 3–5: interpret 401. + // Step 4–6: interpret 401. if resp.status() == reqwest::StatusCode::UNAUTHORIZED { let www_auth = resp .headers() @@ -153,11 +173,23 @@ pub async fn admin_probe( Ok(r) => r, Err(_) => return Ok(AdminProbeResult::NetworkOrIntercepted), }; - return if auth_resp.status().is_success() { - Ok(AdminProbeResult::Nip98Authorized) - } else { - Ok(AdminProbeResult::Nip98Denied) - }; + + // 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() { + // Validate the Nostr header shape was accepted (not just any 2xx). + let bytes = read_bounded(auth_resp, SUCCESS_JSON_CAP).await?; + return if looks_like_admin_list(&bytes) { + Ok(AdminProbeResult::Nip98Authorized) + } else { + // Endpoint exists but didn't return the expected list shape. + Ok(AdminProbeResult::NotAdminApi) + }; + } + return Ok(AdminProbeResult::Nip98Denied); } if www_auth.starts_with("bearer") { @@ -168,11 +200,62 @@ pub async fn admin_probe( return Ok(AdminProbeResult::NotAdminApi); } - if resp.status().is_redirection() { - return Ok(AdminProbeResult::NetworkOrIntercepted); + 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 +} - Ok(AdminProbeResult::NotAdminApi) +/// 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) +} + +/// Returns true when `bytes` deserialises to a JSON array (the shape returned +/// by the `/api/admin/v1/reports?limit=1` endpoint). Used to distinguish a +/// real admin API from a non-admin endpoint or captive-portal JSON. +fn looks_like_admin_list(bytes: &[u8]) -> bool { + matches!( + serde_json::from_slice::(bytes), + Ok(serde_json::Value::Array(_)) + ) } // ── Five typed data commands ────────────────────────────────────────────── @@ -207,8 +290,10 @@ pub async fn admin_get_report( 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: &id }, + &routes::AdminRoute::ReportDetail { id }, &routes::AdminQuery::default(), ); let bytes = fetch_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; @@ -238,8 +323,10 @@ pub async fn admin_get_feedback( 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: &id }, + &routes::AdminRoute::FeedbackDetail { id }, &routes::AdminQuery::default(), ); let bytes = fetch_admin_json(&url, SUCCESS_JSON_CAP, &state).await?; @@ -267,9 +354,10 @@ pub async fn admin_fetch_feedback_attachment( use crate::relay::build_nip98_auth_header_for_keys; // Validate inputs before any network activity. - if sha256.len() != 64 || !sha256.chars().all(|c| c.is_ascii_hexdigit()) { - return Err("admin_attachment_invalid_hash".to_string()); - } + 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()); } @@ -283,8 +371,8 @@ pub async fn admin_fetch_feedback_attachment( let origin = origin::AdminOrigin::parse(&origin)?; let url = origin.route_url( &routes::AdminRoute::FeedbackAttachment { - id: &feedback_id, - sha256: &sha256, + id: feedback_id, + sha256, }, &routes::AdminQuery::default(), ); @@ -328,24 +416,46 @@ pub async fn admin_fetch_feedback_attachment( /// Return the persisted admin console origin for the active pubkey, or `None` /// if none has been saved yet. +/// +/// The stored value is reparsed through `AdminOrigin::parse()` on every read. +/// If the stored content is invalid (e.g. manually edited or from an older +/// format), it is removed and an error returned so the settings card can show +/// a visible setup error rather than silently degrading. #[tauri::command] pub fn get_admin_origin( app: tauri::AppHandle, state: tauri::State<'_, crate::app_state::AppState>, ) -> Result, String> { - let pubkey = state - .signing_keys() - .map(|k| k.public_key().to_hex()) - .unwrap_or_default(); + // Fail closed: never derive the pubkey from an error fallback. + let pubkey = validate_pubkey_hex(state.signing_keys()?.public_key().to_hex())?; let path = admin_origin_path(&app, &pubkey)?; 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 = serde_json::from_str(&content) - .map_err(|e| format!("failed to parse admin console origin: {e}"))?; - Ok(Some(stored.origin)) + let stored: StoredAdminOrigin = match serde_json::from_str(&content) { + Ok(v) => v, + Err(e) => { + // Quarantine invalid content: remove the file so the settings card + // shows a clear setup error rather than looping with a stale value. + let _ = std::fs::remove_file(&path); + return Err(format!( + "stored admin console origin is invalid (removed): {e}" + )); + } + }; + // Reparse through AdminOrigin::parse() so the returned value is always + // canonical, even if the file was written by an older version. + match origin::AdminOrigin::parse(&stored.origin) { + Ok(o) => Ok(Some(o.as_str().to_string())), + Err(e) => { + let _ = std::fs::remove_file(&path); + Err(format!( + "stored admin console origin is invalid (removed): {e}" + )) + } + } } /// Validate and persist the admin console origin for the active pubkey. @@ -360,10 +470,8 @@ pub fn set_admin_origin( ) -> Result, String> { use crate::managed_agents::storage::atomic_write_json_restricted; - let pubkey = state - .signing_keys() - .map(|k| k.public_key().to_hex()) - .unwrap_or_default(); + // Fail closed: never derive the pubkey from an error fallback. + let pubkey = validate_pubkey_hex(state.signing_keys()?.public_key().to_hex())?; let path = admin_origin_path(&app, &pubkey)?; match raw_origin { @@ -407,6 +515,20 @@ fn admin_origin_path( Ok(dir.join(format!("admin-console-origin-{pubkey_hex}.json"))) } +/// 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()) + } +} + // ── Internal helpers ────────────────────────────────────────────────────── /// Fetch a JSON endpoint with NIP-98 auth, one 401-retry, and a size cap. @@ -584,52 +706,55 @@ mod tests { assert!(url.starts_with("http://localhost:3000/api/admin/v1/")); } - // ── Attachment command validation ───────────────────────────────────────── - // - // These are pure-logic tests that do not require a live Tauri state. + // ── Attachment command validation (calls production validators) ─────────── - fn valid_hash() -> String { - "a".repeat(64) + #[test] + fn attachment_hash_valid_lowercase_hex_accepted() { + // Calls the real AttachmentHash::parse production validator. + let result = routes::AttachmentHash::parse(&"a".repeat(64)); + assert!(result.is_ok(), "64 lowercase hex chars must be accepted"); } #[test] - fn attachment_hash_must_be_64_hex_chars() { - // Valid: 64 lowercase hex chars. - let h = valid_hash(); - assert_eq!(h.len(), 64); - assert!(h.chars().all(|c| c.is_ascii_hexdigit())); - - // Invalid: 63 chars (too short). - let short: String = "a".repeat(63); - assert_ne!(short.len(), 64); + 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" + ); + } - // Invalid: non-hex character ('g' is not a hex digit). - let non_hex: String = "g".repeat(64); - assert!(non_hex.chars().any(|c| !c.is_ascii_hexdigit())); + #[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"); + } - // Note: uppercase A-F ARE valid hex digits per is_ascii_hexdigit(). - // The production guard rejects them only if is_ascii_hexdigit() returns - // false. Uppercase input like "AAAA...AAAA" (64 chars) would pass the - // length+hexdigit check — callers must normalise to lowercase if needed. - let upper_hex: String = "A".repeat(64); - assert_eq!(upper_hex.len(), 64); - assert!(upper_hex.chars().all(|c| c.is_ascii_hexdigit())); + #[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 attachment_size_zero_is_invalid() { - assert_eq!(0u64, 0); - // Production guard: expected_size == 0 yields admin_attachment_invalid_size. + 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 attachment_over_cap_is_invalid() { - let over_cap = ATTACHMENT_CAP + 1; - assert!(over_cap > ATTACHMENT_CAP); - // Production guard: expected_size > ATTACHMENT_CAP yields admin_attachment_too_large. + 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 logic ─────────────────────────────────────────── + // ── Content-Type matching (calls production logic) ──────────────────────── #[test] fn content_type_matching_is_case_insensitive_and_strips_params() { @@ -638,7 +763,167 @@ mod tests { let raw = "Image/PNG; charset=binary"; let normalised = raw.split(';').next().unwrap().trim().to_ascii_lowercase(); assert_eq!(normalised, "image/png"); - // This would match expected_mime "image/png". assert_eq!(normalised, "image/png".trim().to_ascii_lowercase()); } + + // ── looks_like_admin_list ───────────────────────────────────────────────── + + #[test] + fn looks_like_admin_list_json_array() { + assert!(looks_like_admin_list(b"[]")); + assert!(looks_like_admin_list(b"[{\"id\":\"abc\"}]")); + } + + #[test] + fn looks_like_admin_list_rejects_non_array() { + assert!(!looks_like_admin_list(b"{}")); + assert!(!looks_like_admin_list(b"\"string\"")); + assert!(!looks_like_admin_list(b"null")); + assert!(!looks_like_admin_list(b"captive portal")); + assert!(!looks_like_admin_list(b"not json")); + } + + // ── is_probe_response_intercepted (via live stub server) ───────────────── + + /// Build a fake Response using a live TCP listener that serves the given + /// status + headers + body, then returns the parsed reqwest::Response. + async fn fake_response(status: u16, headers: &str, body: &str) -> reqwest::Response { + use client::ADMIN_CLIENT; + use std::io::{Read, Write}; + client::init_admin_client(); + let 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\n\ + Content-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() + } + + #[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), + "HTML 200 must be intercepted" + ); + } + + #[tokio::test] + async fn probe_json_200_not_classified_as_intercepted() { + let resp = fake_response(200, "Content-Type: application/json\r\n", "[]").await; + assert!( + !is_probe_response_intercepted(&resp), + "JSON 200 must not be intercepted" + ); + } + + #[tokio::test] + async fn probe_html_200_returns_network_or_intercepted() { + client::init_admin_client(); + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + let body = b"sign in"; + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = stream.write_all(resp.as_bytes()); + let _ = stream.write_all(body); + let _ = stream.flush(); + } + }); + let client = client::ADMIN_CLIENT.get().unwrap(); + let raw_resp = client + .get(format!("http://{addr}/api/admin/v1/reports")) + .send() + .await + .unwrap(); + assert!(is_probe_response_intercepted(&raw_resp)); + } + + #[tokio::test] + async fn probe_json_200_looks_like_admin_list() { + let resp = fake_response(200, "Content-Type: application/json\r\n", "[]").await; + assert!(!is_probe_response_intercepted(&resp)); + let bytes = read_bounded(resp, SUCCESS_JSON_CAP).await.unwrap(); + assert!(looks_like_admin_list(&bytes)); + } + + #[tokio::test] + async fn probe_malformed_json_200_returns_not_admin_api() { + let resp = + fake_response(200, "Content-Type: application/json\r\n", "not json at all").await; + assert!(!is_probe_response_intercepted(&resp)); + let bytes = read_bounded(resp, SUCCESS_JSON_CAP).await.unwrap(); + assert!(!looks_like_admin_list(&bytes)); + } + + #[tokio::test] + async fn probe_json_object_200_returns_not_admin_api() { + // A JSON object (not array) is not the expected list shape. + let resp = fake_response( + 200, + "Content-Type: application/json\r\n", + "{\"error\":\"not the admin api\"}", + ) + .await; + assert!(!is_probe_response_intercepted(&resp)); + let bytes = read_bounded(resp, SUCCESS_JSON_CAP).await.unwrap(); + assert!(!looks_like_admin_list(&bytes)); + } + + // ── validate_pubkey_hex ─────────────────────────────────────────────────── + + #[test] + fn pubkey_hex_valid_64_lowercase() { + let valid = "a".repeat(64); + assert!(validate_pubkey_hex(valid).is_ok()); + } + + #[test] + fn pubkey_hex_uppercase_rejected() { + let upper = "A".repeat(64); + assert!(validate_pubkey_hex(upper).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()); + } } diff --git a/desktop/src-tauri/src/commands/admin/origin.rs b/desktop/src-tauri/src/commands/admin/origin.rs index 1d09044296..643e93097c 100644 --- a/desktop/src-tauri/src/commands/admin/origin.rs +++ b/desktop/src-tauri/src/commands/admin/origin.rs @@ -96,7 +96,7 @@ impl AdminOrigin { } /// Build the full request URL for `route` with `query`. - pub fn route_url(&self, route: &AdminRoute<'_>, query: &AdminQuery) -> String { + pub fn route_url(&self, route: &AdminRoute, query: &AdminQuery) -> String { let path = route.path(); let qs = query.to_query_string(); if qs.is_empty() { @@ -114,7 +114,7 @@ fn is_loopback_host(host: &str) -> bool { || host == "[::1]" || host .parse::() - .map_or(false, |ip| ip.is_loopback()) + .is_ok_and(|ip| ip.is_loopback()) } #[cfg(test)] @@ -214,26 +214,27 @@ mod tests { #[test] fn route_url_report_detail() { - let id = "abc-123"; + 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/abc-123" + "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: "fb-id", - sha256: "abcdef01".repeat(8).as_str(), - }, + &AdminRoute::FeedbackAttachment { id, sha256 }, &AdminQuery::default(), ); - assert!(url.contains("/api/admin/v1/feedback/fb-id/attachments/")); + assert!(url.contains("/api/admin/v1/feedback/")); + assert!(url.contains("/attachments/")); } // ── Host case pin test ──────────────────────────────────────────────────── diff --git a/desktop/src-tauri/src/commands/admin/routes.rs b/desktop/src-tauri/src/commands/admin/routes.rs index 0eea171f0e..7504ec463c 100644 --- a/desktop/src-tauri/src/commands/admin/routes.rs +++ b/desktop/src-tauri/src/commands/admin/routes.rs @@ -1,22 +1,62 @@ //! 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. +//! 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 five read routes exposed by `/api/admin/v1`. /// -/// Named `'_` lifetime on borrowed id/sha256 fields so callers can pass -/// `&str` slices without allocating. +/// 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. #[derive(Debug)] -pub enum AdminRoute<'a> { +pub enum AdminRoute { ReportsList, - ReportDetail { id: &'a str }, + ReportDetail { + id: uuid::Uuid, + }, FeedbackList, - FeedbackDetail { id: &'a str }, - FeedbackAttachment { id: &'a str, sha256: &'a str }, + FeedbackDetail { + id: uuid::Uuid, + }, + FeedbackAttachment { + id: uuid::Uuid, + sha256: AttachmentHash, + }, } -impl<'a> AdminRoute<'a> { +impl AdminRoute { /// Return the URL path component (not including the `/api/admin/v1` prefix). pub fn path(&self) -> String { match self { @@ -25,7 +65,7 @@ impl<'a> AdminRoute<'a> { AdminRoute::FeedbackList => "/feedback".to_string(), AdminRoute::FeedbackDetail { id } => format!("/feedback/{id}"), AdminRoute::FeedbackAttachment { id, sha256 } => { - format!("/feedback/{id}/attachments/{sha256}") + format!("/feedback/{id}/attachments/{}", sha256.as_str()) } } } @@ -85,6 +125,70 @@ fn urlencoded(value: &str) -> String { 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"); @@ -92,25 +196,33 @@ mod tests { #[test] fn report_detail_path() { + let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); assert_eq!( - AdminRoute::ReportDetail { id: "abc-123" }.path(), - "/reports/abc-123" + AdminRoute::ReportDetail { id }.path(), + "/reports/00000000-0000-0000-0000-000000000001" ); } #[test] fn feedback_attachment_path() { - let hash = "a".repeat(64); + 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!( - AdminRoute::FeedbackAttachment { - id: "fb-id", - sha256: &hash - } - .path(), - format!("/feedback/fb-id/attachments/{hash}") + 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(), ""); diff --git a/desktop/src/features/admin-console/AdminConsolePanel.tsx b/desktop/src/features/admin-console/AdminConsolePanel.tsx index c1ab288286..135043b865 100644 --- a/desktop/src/features/admin-console/AdminConsolePanel.tsx +++ b/desktop/src/features/admin-console/AdminConsolePanel.tsx @@ -4,12 +4,15 @@ * Shows two tabs: Reports (deployment-wide moderation reports) and Feedback * (product feedback with optional image attachments). * - * All query/UI state is keyed by `(origin)`, which is already scoped to the - * active pubkey by the settings card (pubkey changed → different origin stored). - * In-flight requests are cancelled on origin change via useEffect cleanup. + * All query/UI state is keyed by `(pubkey, origin)`. In-flight native requests + * are fenced by a generation counter: each (pubkey, origin) pair gets a new + * generation; results from prior generations are discarded on arrival. + * + * Tauri invoke is not cancellable at the native layer, but generation checks + * ensure stale results never update visible state or create unreachable blob URLs. */ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { AlertCircle, ChevronLeft, @@ -37,46 +40,135 @@ type AsyncState = | { status: "ok"; data: T } | { status: "error"; message: string }; +/** + * Generation-fenced async load hook. + * + * `generation` increments whenever the caller wants to invalidate all + * in-flight results (e.g. pubkey or origin changed). Results that arrive + * after the generation changed are silently dropped. + */ function useAsyncLoad( - load: (signal: AbortSignal) => Promise, + load: () => Promise, deps: unknown[], + generation: number, ): AsyncState { const [state, setState] = useState>({ status: "idle" }); + // Store load in a ref so the effect doesn't need it as a dependency — + // callers create it inline and deps + generation are the real trigger list. + const loadRef = useRef(load); + loadRef.current = load; useEffect(() => { - const controller = new AbortController(); + const gen = generation; setState({ status: "loading" }); - load(controller.signal).then( + loadRef.current().then( (data) => { - if (!controller.signal.aborted) setState({ status: "ok", data }); + setState((prev) => { + // Discard if the generation changed while we were in flight. + if (gen !== generation) return prev; + return { status: "ok", data }; + }); }, (e: unknown) => { - if (!controller.signal.aborted) { - setState({ + setState((prev) => { + if (gen !== generation) return prev; + return { status: "error", message: e instanceof Error ? e.message : String(e), - }); - } + }; + }); }, ); - return () => controller.abort(); - // biome-ignore lint/correctness/useExhaustiveDependencies: deps array is passed from the call site as the explicit trigger list; adding `load` would cause infinite re-runs since it's created inline - }, deps); + // loadRef is stable and excluded from deps — the ref stores the latest `load` + // without making it a reactive value. + }, [...deps, generation]); return state; } +// ── imeta attachment parsing ────────────────────────────────────────────── + +/** + * Validated attachment metadata parsed from a feedback detail's `tags` field. + * The relay serialises `AdminFeedback` with `serde(rename_all = "camelCase")`, + * so the wire shape is `{ ..., tags: string[][] }`. + */ +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. + */ +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; +} + // ── Reports tab ─────────────────────────────────────────────────────────── -function ReportsTab({ origin }: { origin: string }) { +function ReportsTab({ + origin, + pubkey, + generation, +}: { + origin: string; + pubkey: string; + generation: number; +}) { const [selectedId, setSelectedId] = useState(null); - const listState = useAsyncLoad(() => listAdminReports(origin), [origin]); + const listState = useAsyncLoad( + () => listAdminReports(origin), + [origin, pubkey], + generation, + ); if (selectedId) { return ( setSelectedId(null)} /> @@ -101,7 +193,7 @@ function ReportsTab({ origin }: { origin: string }) { {reports.map((report) => { const id = String(report.id ?? report.reportId ?? ""); const summary = - String(report.summary ?? report.report_type ?? "") || "Report"; + String(report.summary ?? report.reportType ?? "") || "Report"; const status = String(report.status ?? ""); return (
  • @@ -124,16 +216,21 @@ function ReportsTab({ origin }: { origin: string }) { function ReportDetail({ origin, + pubkey, + generation, reportId, onBack, }: { origin: string; + pubkey: string; + generation: number; reportId: string; onBack: () => void; }) { const detailState = useAsyncLoad( () => getAdminReport(origin, reportId), - [origin, reportId], + [origin, pubkey, reportId], + generation, ); return ( @@ -161,10 +258,22 @@ function ReportDetail({ // ── Feedback tab ────────────────────────────────────────────────────────── -function FeedbackTab({ origin }: { origin: string }) { +function FeedbackTab({ + origin, + pubkey, + generation, +}: { + origin: string; + pubkey: string; + generation: number; +}) { const [selectedId, setSelectedId] = useState(null); - const listState = useAsyncLoad(() => listAdminFeedback(origin), [origin]); + const listState = useAsyncLoad( + () => listAdminFeedback(origin), + [origin, pubkey], + generation, + ); if (selectedId) { return ( @@ -172,6 +281,8 @@ function FeedbackTab({ origin }: { origin: string }) { feedbackId={selectedId} onBack={() => setSelectedId(null)} origin={origin} + pubkey={pubkey} + generation={generation} /> ); } @@ -191,10 +302,9 @@ function FeedbackTab({ origin }: { origin: string }) {
      {items.map((item) => { const id = String(item.id ?? item.feedbackId ?? ""); - const text = String( - item.feedback ?? item.message ?? item.content ?? "", - ).slice(0, 120); - const createdAt = String(item.created_at ?? item.createdAt ?? ""); + // FeedbackSummary wire shape: bodySummary, receivedAt (camelCase via serde). + const text = String(item.bodySummary ?? item.body ?? "").slice(0, 120); + const createdAt = String(item.receivedAt ?? item.eventCreatedAt ?? ""); return (