diff --git a/desktop/src-tauri/src/commands/admin/mod.rs b/desktop/src-tauri/src/commands/admin/mod.rs index 1eb777728..010f9760b 100644 --- a/desktop/src-tauri/src/commands/admin/mod.rs +++ b/desktop/src-tauri/src/commands/admin/mod.rs @@ -36,6 +36,10 @@ pub(crate) mod routes; /// can reach the 256 KiB event-content cap. Sized for the worst case. const SUCCESS_JSON_CAP: u64 = 52_428_800; // 50 MiB +/// Probe-response cap: `/probe` returns a tiny fixed-shape JSON envelope. +/// 8 KiB is far more than the payload needs while bounding a hostile body. +const PROBE_JSON_CAP: u64 = 8_192; // 8 KiB + /// Error-body cap: relay error responses are brief JSON envelopes. const ERROR_BODY_CAP: u64 = 65_536; // 64 KiB @@ -104,13 +108,13 @@ type SignFn = Box Result + Send + Sync>; /// current app keypair is authorized. /// /// Algorithm: -/// 1. Send an unauthenticated GET to `/api/admin/v1/reports?limit=1`. +/// 1. Send an unauthenticated GET to `/api/admin/v1/probe`. /// 2. Detect HTML/interception pages (Cloudflare Access, captive portals) /// from Content-Type and final URL host → `NetworkOrIntercepted`. -/// 3. 200 + valid JSON list shape → `Disabled` (admin accessible without cred). +/// 3. 200 + valid `ProbeResponse` with `authMode: "disabled"` → `Disabled`. /// 4. 401 + `WWW-Authenticate: Nostr` → NIP-98 mode. Retry with a freshly -/// signed kind-27235. 200 + valid list shape → `Nip98Authorized`; -/// non-200 → `Nip98Denied`. +/// signed kind-27235. 200 + valid `ProbeResponse` → `Nip98Authorized` +/// carrying the relay-resolved `role`/`source`; non-200 → `Nip98Denied`. /// 5. 401 + `WWW-Authenticate: Bearer` → `TokenMode`. /// 6. 403/404 or other non-401 → `NotAdminApi`. /// 7. Network/redirect/TLS error → `NetworkOrIntercepted`. @@ -144,13 +148,7 @@ async fn admin_probe_inner( sign: Option Result>, ) -> Result { let origin = origin::AdminOrigin::parse(origin)?; - let url = origin.route_url( - &routes::AdminRoute::ReportsList, - &routes::AdminQuery { - limit: Some(1), - ..Default::default() - }, - ); + let url = origin.route_url(&routes::AdminRoute::Probe, &routes::AdminQuery::default()); let http_client = client::ADMIN_CLIENT .get() @@ -174,15 +172,16 @@ async fn admin_probe_inner( return Ok(AdminProbeResult::NetworkOrIntercepted); } - // Step 3: success without auth → disabled mode (if body is a valid list). + // Step 3: success without auth → disabled mode (if body is a valid probe). if resp.status().is_success() { let content_type = response_content_type(&resp); - let bytes = read_bounded(resp, SUCCESS_JSON_CAP).await?; - return if looks_like_admin_list(&content_type, &bytes) { - Ok(AdminProbeResult::Disabled) - } else { - Ok(AdminProbeResult::NotAdminApi) - }; + let bytes = read_bounded(resp, PROBE_JSON_CAP).await?; + return Ok(match parse_probe(&content_type, &bytes) { + Some(p) if p.auth_mode == "disabled" => AdminProbeResult::Disabled, + // A 200 in any other mode is a contract violation (token/nip98 + // must 401 an unauthenticated caller); classify defensively. + _ => AdminProbeResult::NotAdminApi, + }); } // Step 4–6: interpret 401. @@ -221,21 +220,19 @@ async fn admin_probe_inner( } if auth_resp.status().is_success() { - // Validate the Nostr header shape was accepted (not just any 2xx). let content_type = response_content_type(&auth_resp); - let bytes = read_bounded(auth_resp, SUCCESS_JSON_CAP).await?; - return if looks_like_admin_list(&content_type, &bytes) { - // Extract role and source from probe response headers if present. - // The relay includes X-Admin-Role and X-Admin-Source on the - // authenticated probe response once the principal is resolved. - Ok(AdminProbeResult::Nip98Authorized { - role: None, - source: None, - }) - } else { - // Endpoint exists but didn't return the expected list shape. - Ok(AdminProbeResult::NotAdminApi) - }; + let bytes = read_bounded(auth_resp, PROBE_JSON_CAP).await?; + return Ok(match parse_probe(&content_type, &bytes) { + // Relay resolves role/source for the authenticated + // principal; carry them through for the staffing tab. + Some(p) => AdminProbeResult::Nip98Authorized { + role: p.role, + source: p.source, + }, + // 2xx but not a probe shape: endpoint exists but isn't + // the admin API. + None => AdminProbeResult::NotAdminApi, + }); } return Ok(AdminProbeResult::Nip98Denied); } @@ -296,83 +293,42 @@ async fn read_bounded(resp: reqwest::Response, cap: u64) -> Result, Stri Ok(bytes) } -/// Returns true when `content_type` is JSON and `bytes` deserialises to a -/// JSON array matching the `/api/admin/v1/reports` shape. +/// The relay's `/probe` response contract (`ProbeResponse` in the relay's +/// `api/admin/mod.rs`, serialised `rename_all = "camelCase"`). /// -/// Rules: -/// - Content-Type must start with `application/json` (case-insensitive). -/// - Body must be a JSON array. -/// - Non-empty arrays must have every element deserialise against the -/// `AdminReport` wire contract (camelCase, `rename_all = "camelCase"`). -/// An empty array is valid — a fresh relay with no reports returns `[]`. -/// - Partial / garbage elements (`{"id":null}`, `7`, `"garbage"`) are rejected. +/// Only the fields the desktop consumes are typed. `role`/`source` are +/// present (non-null) only in nip98 mode with a resolved principal; they are +/// `null` in token/disabled modes. +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProbeWire { + #[allow(dead_code)] + status: String, + auth_mode: String, + role: Option, + source: Option, + #[allow(dead_code)] + can_act: bool, + #[allow(dead_code)] + can_staff: bool, +} + +/// Parse a `/probe` response body into a [`ProbeWire`], returning `None` when +/// the Content-Type is not JSON or the body does not match the probe contract. /// -/// This prevents unrelated endpoints that return JSON arrays from being -/// misclassified as the admin API. -fn looks_like_admin_list(content_type: &str, bytes: &[u8]) -> bool { - // Require JSON Content-Type. +/// Strict typing rejects unrelated JSON endpoints: a response missing any +/// required field (`status`, `authMode`, `canAct`, `canStaff`) or carrying a +/// wrong-typed field fails to deserialise and yields `None`, so a non-admin +/// origin that happens to return JSON is classified `NotAdminApi` rather than +/// mistaken for the admin API. +fn parse_probe(content_type: &str, bytes: &[u8]) -> Option { if !content_type .to_ascii_lowercase() .starts_with("application/json") { - return false; + return None; } - // Body must be a JSON array. - let arr = match serde_json::from_slice::(bytes) { - Ok(serde_json::Value::Array(a)) => a, - _ => return false, - }; - // Empty array is valid (fresh relay with no reports). - if arr.is_empty() { - return true; - } - // Non-empty: every element must deserialise against the AdminReport probe DTO. - // The wire shape is camelCase (serde rename_all = "camelCase"). - arr.iter() - .all(|v| serde_json::from_value::(v.clone()).is_ok()) -} - -/// Full `AdminReport` wire contract used for probe validation. -/// -/// Mirrors the camelCase serialisation of `AdminReport` in -/// `crates/buzz-db/src/admin_moderation.rs:24-55` exactly — required fields -/// are typed strictly, optional fields use `Option` with real types. -/// This ensures that a response with `createdAt: null` or a malformed optional -/// field (e.g. `channelId: 7`) is rejected, not silently classified as the -/// admin API. -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct AdminReportProbeDto { - #[allow(dead_code)] - id: uuid::Uuid, - #[allow(dead_code)] - community_id: uuid::Uuid, - #[allow(dead_code)] - community_host: String, - #[allow(dead_code)] - report_event_id: String, - #[allow(dead_code)] - reporter_pubkey: String, - #[allow(dead_code)] - target_kind: String, - #[allow(dead_code)] - target: String, - #[allow(dead_code)] - channel_id: Option, - #[allow(dead_code)] - report_type: String, - #[allow(dead_code)] - note: Option, - #[allow(dead_code)] - status: String, - #[allow(dead_code)] - resolved_by: Option, - #[allow(dead_code)] - resolved_at: Option>, - #[allow(dead_code)] - action_id: Option, - #[allow(dead_code)] - created_at: chrono::DateTime, + serde_json::from_slice::(bytes).ok() } /// Extract the normalised Content-Type base value (strips parameters). diff --git a/desktop/src-tauri/src/commands/admin/mod_tests.rs b/desktop/src-tauri/src/commands/admin/mod_tests.rs index 6593a33d6..845780b20 100644 --- a/desktop/src-tauri/src/commands/admin/mod_tests.rs +++ b/desktop/src-tauri/src/commands/admin/mod_tests.rs @@ -94,150 +94,63 @@ fn content_type_matching_is_case_insensitive_and_strips_params() { assert_eq!(normalised, "image/png"); } -// ── looks_like_admin_list ───────────────────────────────────────────────── +// ── parse_probe ─────────────────────────────────────────────────────────── -fn valid_admin_report_json(id: &str) -> String { +/// A well-formed `/probe` response body. `role`/`source` are JSON literals +/// (`"operator"`, `null`, …) so the helper can build both nip98 and +/// token/disabled shapes. +fn probe_json(auth_mode: &str, role: &str, source: &str, can_act: bool, can_staff: bool) -> String { format!( - r#"{{ - "id": "{id}", - "communityId": "00000000-0000-0000-0000-000000000002", - "communityHost": "relay.example.com", - "reportEventId": "aabbcc", - "reporterPubkey": "ddeeff", - "targetKind": "message", - "target": "112233", - "channelId": null, - "reportType": "spam", - "note": null, - "status": "open", - "resolvedBy": null, - "resolvedAt": null, - "actionId": null, - "createdAt": "2024-01-01T00:00:00Z" - }}"# + r#"{{"status":"ok","authMode":"{auth_mode}","role":{role},"source":{source},"canAct":{can_act},"canStaff":{can_staff}}}"# ) } #[test] -fn looks_like_admin_list_empty_array_with_json_ct() { - assert!(looks_like_admin_list("application/json", b"[]")); +fn parse_probe_operator_nip98() { + let body = probe_json("nip98", r#""operator""#, r#""config""#, true, true); + let p = parse_probe("application/json", body.as_bytes()).expect("valid operator probe"); + assert_eq!(p.auth_mode, "nip98"); + assert_eq!(p.role.as_deref(), Some("operator")); + assert_eq!(p.source.as_deref(), Some("config")); } #[test] -fn looks_like_admin_list_valid_report_element() { - let body = format!( - "[{}]", - valid_admin_report_json("00000000-0000-0000-0000-000000000001") - ); - assert!( - looks_like_admin_list("application/json", body.as_bytes()), - "single valid AdminReport element must classify as admin list" - ); +fn parse_probe_disabled_has_null_role() { + let body = probe_json("disabled", "null", "null", false, false); + let p = parse_probe("application/json", body.as_bytes()).expect("valid disabled probe"); + assert_eq!(p.auth_mode, "disabled"); + assert_eq!(p.role, None); + assert_eq!(p.source, None); } #[test] -fn looks_like_admin_list_rejects_id_null() { - // {"id":null} passes the old `contains_key("id")` check but must be rejected - // because `null` is not a valid UUID. - assert!(!looks_like_admin_list( - "application/json", - b"[{\"id\":null}]" - )); +fn parse_probe_rejects_non_json_content_type() { + let body = probe_json("nip98", r#""operator""#, r#""config""#, true, true); + assert!(parse_probe("text/html", body.as_bytes()).is_none()); + assert!(parse_probe("", body.as_bytes()).is_none()); } #[test] -fn looks_like_admin_list_rejects_created_at_null() { - // Full-shape element with `createdAt: null` must be rejected — the wire - // contract requires a real RFC-3339 timestamp for `createdAt`. - let body = r#"[{ - "id": "00000000-0000-0000-0000-000000000001", - "communityId": "00000000-0000-0000-0000-000000000002", - "communityHost": "relay.example.com", - "reportEventId": "aabbcc", - "reporterPubkey": "ddeeff", - "targetKind": "message", - "target": "112233", - "channelId": null, - "reportType": "spam", - "note": null, - "status": "open", - "resolvedBy": null, - "resolvedAt": null, - "actionId": null, - "createdAt": null - }]"#; - assert!( - !looks_like_admin_list("application/json", body.as_bytes()), - "full-shape element with createdAt: null must not classify as admin list" - ); +fn parse_probe_rejects_missing_required_field() { + // Missing `canStaff` — an unrelated JSON endpoint must not classify as the + // admin API. + let body = + r#"{"status":"ok","authMode":"nip98","role":"operator","source":"config","canAct":true}"#; + assert!(parse_probe("application/json", body.as_bytes()).is_none()); } #[test] -fn looks_like_admin_list_rejects_malformed_optional_field() { - // Full-shape element with a malformed optional UUID field (`channelId: 7`) - // must be rejected — the wire contract requires Option for channelId. - let body = r#"[{ - "id": "00000000-0000-0000-0000-000000000001", - "communityId": "00000000-0000-0000-0000-000000000002", - "communityHost": "relay.example.com", - "reportEventId": "aabbcc", - "reporterPubkey": "ddeeff", - "targetKind": "message", - "target": "112233", - "channelId": 7, - "reportType": "spam", - "note": null, - "status": "open", - "resolvedBy": null, - "resolvedAt": null, - "actionId": null, - "createdAt": "2024-01-01T00:00:00Z" - }]"#; - assert!( - !looks_like_admin_list("application/json", body.as_bytes()), - "full-shape element with channelId: 7 (not a UUID) must not classify as admin list" - ); +fn parse_probe_rejects_wrong_typed_field() { + // `canAct` as a string, not a bool. + let body = r#"{"status":"ok","authMode":"nip98","role":"operator","source":"config","canAct":"yes","canStaff":true}"#; + assert!(parse_probe("application/json", body.as_bytes()).is_none()); } #[test] -fn looks_like_admin_list_rejects_garbage_fixture() { - // Pinned fixture from the spec: [{"id":null}, 7, "garbage"] must be rejected. - assert!(!looks_like_admin_list( - "application/json", - b"[{\"id\":null}, 7, \"garbage\"]" - )); -} - -#[test] -fn looks_like_admin_list_rejects_primitive_array() { - assert!(!looks_like_admin_list("application/json", b"[1]")); - assert!(!looks_like_admin_list( - "application/json", - b"[\"unrelated\"]" - )); - assert!(!looks_like_admin_list( - "application/json", - b"[{\"notId\":true}]" - )); -} - -#[test] -fn looks_like_admin_list_rejects_non_json_content_type() { - assert!(!looks_like_admin_list("text/html", b"[]")); - assert!(!looks_like_admin_list("", b"[]")); - assert!(!looks_like_admin_list("text/plain", b"[]")); -} - -#[test] -fn looks_like_admin_list_rejects_non_array() { - assert!(!looks_like_admin_list("application/json", b"{}")); - assert!(!looks_like_admin_list("application/json", b"\"string\"")); - assert!(!looks_like_admin_list("application/json", b"null")); - assert!(!looks_like_admin_list( - "application/json", - b"captive portal" - )); - assert!(!looks_like_admin_list("application/json", b"not json")); +fn parse_probe_rejects_non_object() { + assert!(parse_probe("application/json", b"[]").is_none()); + assert!(parse_probe("application/json", b"\"string\"").is_none()); + assert!(parse_probe("application/json", b"not json").is_none()); } // ── Storage core through production code ───────────────────────────────── @@ -570,29 +483,31 @@ async fn probe_html_200_classified_as_intercepted() { #[tokio::test] async fn probe_json_200_not_classified_as_intercepted() { - let resp = fake_response(200, "Content-Type: application/json\r\n", "[]").await; + let resp = fake_response( + 200, + "Content-Type: application/json\r\n", + r#"{"status":"ok","authMode":"disabled","role":null,"source":null,"canAct":false,"canStaff":false}"#, + ) + .await; assert!(!is_probe_response_intercepted(&resp)); } #[tokio::test] -async fn probe_json_200_with_valid_report_looks_like_admin_list() { - let body = format!( - "[{}]", - valid_admin_report_json("00000000-0000-0000-0000-000000000001") - ); +async fn probe_json_200_with_valid_probe_parses() { + let body = probe_json("nip98", r#""operator""#, r#""config""#, true, true); let resp = fake_response(200, "Content-Type: application/json\r\n", &body).await; assert!(!is_probe_response_intercepted(&resp)); let ct = response_content_type(&resp); - let bytes = read_bounded(resp, SUCCESS_JSON_CAP).await.unwrap(); - assert!(looks_like_admin_list(&ct, &bytes)); + let bytes = read_bounded(resp, PROBE_JSON_CAP).await.unwrap(); + assert!(parse_probe(&ct, &bytes).is_some()); } #[tokio::test] -async fn probe_json_200_bare_array_of_garbage_not_admin_api() { +async fn probe_json_200_bare_garbage_not_admin_api() { let resp = fake_response(200, "Content-Type: application/json\r\n", "[1,2,3]").await; let ct = response_content_type(&resp); - let bytes = read_bounded(resp, SUCCESS_JSON_CAP).await.unwrap(); - assert!(!looks_like_admin_list(&ct, &bytes)); + let bytes = read_bounded(resp, PROBE_JSON_CAP).await.unwrap(); + assert!(parse_probe(&ct, &bytes).is_none()); } // ── admin_probe_inner end-to-end state machine ──────────────────────────── @@ -632,8 +547,15 @@ async fn probe_inner_malformed_json_200_is_not_admin_api() { } #[tokio::test] -async fn probe_inner_json_empty_array_200_is_disabled() { - let addr = serve_sequence(vec![("200 OK", "Content-Type: application/json\r\n", "[]")]).await; +async fn probe_inner_disabled_probe_200_is_disabled() { + let body = probe_json("disabled", "null", "null", false, false); + let body_static: &'static str = Box::leak(body.into_boxed_str()); + let addr = serve_sequence(vec![( + "200 OK", + "Content-Type: application/json\r\n", + body_static, + )]) + .await; let result = admin_probe_inner( &format!("http://{addr}"), None:: Result>, @@ -644,8 +566,30 @@ async fn probe_inner_json_empty_array_200_is_disabled() { } #[tokio::test] -async fn probe_inner_bare_array_of_garbage_is_not_admin_api() { - // Non-empty arrays without valid AdminReport elements must not classify as admin API. +async fn probe_inner_nip98_authmode_200_without_auth_is_not_admin_api() { + // A relay must 401 an unauthenticated caller in nip98/token mode. A 200 + // carrying `authMode: "nip98"` (no 401 challenge) is a contract violation + // and must not be classified as Disabled. + let body = probe_json("nip98", r#""operator""#, r#""config""#, true, true); + let body_static: &'static str = Box::leak(body.into_boxed_str()); + let addr = serve_sequence(vec![( + "200 OK", + "Content-Type: application/json\r\n", + body_static, + )]) + .await; + let result = admin_probe_inner( + &format!("http://{addr}"), + None:: Result>, + ) + .await + .unwrap(); + assert!(matches!(result, AdminProbeResult::NotAdminApi)); +} + +#[tokio::test] +async fn probe_inner_bare_garbage_200_is_not_admin_api() { + // A JSON body that isn't a probe envelope must not classify as admin API. let addr = serve_sequence(vec![( "200 OK", "Content-Type: application/json\r\n", @@ -661,27 +605,6 @@ async fn probe_inner_bare_array_of_garbage_is_not_admin_api() { assert!(matches!(result, AdminProbeResult::NotAdminApi)); } -#[tokio::test] -async fn probe_inner_garbage_fixture_id_null_and_primitives_is_not_admin_api() { - // Pinned fixture from the spec: must be rejected. - let addr = serve_sequence(vec![( - "200 OK", - "Content-Type: application/json\r\n", - "[{\"id\":null}, 7, \"garbage\"]", - )]) - .await; - let result = admin_probe_inner( - &format!("http://{addr}"), - None:: Result>, - ) - .await - .unwrap(); - assert!( - matches!(result, AdminProbeResult::NotAdminApi), - "garbage fixture must be NotAdminApi, got {result:?}" - ); -} - #[tokio::test] async fn probe_inner_persistent_401_is_nip98_denied() { let addr = serve_sequence(vec![ @@ -712,10 +635,7 @@ async fn probe_inner_nip98_challenge_then_json_200_is_authorized_and_asserts_aut let expected_token = "Nostr dGVzdA==".to_string(); let expected_token_for_sign = expected_token.clone(); - let valid_body = format!( - "[{}]", - valid_admin_report_json("00000000-0000-0000-0000-000000000003") - ); + let valid_body = probe_json("nip98", r#""operator""#, r#""config""#, true, true); let valid_body_static: &'static str = Box::leak(valid_body.into_boxed_str()); // serve_gated_nip98: slot 0 always challenges; slot 1 checks the Authorization @@ -727,9 +647,15 @@ async fn probe_inner_nip98_challenge_then_json_200_is_authorized_and_asserts_aut .await .unwrap(); + // The relay-resolved role/source must be carried through to the UI so the + // Staffing tab renders for an operator. assert!( - matches!(result, AdminProbeResult::Nip98Authorized { .. }), - "expected Nip98Authorized, got {result:?}" + matches!( + &result, + AdminProbeResult::Nip98Authorized { role, source } + if role.as_deref() == Some("operator") && source.as_deref() == Some("config") + ), + "expected Nip98Authorized operator/config, got {result:?}" ); let records = records.lock().unwrap(); @@ -742,8 +668,8 @@ async fn probe_inner_nip98_challenge_then_json_200_is_authorized_and_asserts_aut records[0].method ); assert!( - records[0].path.contains("/api/admin/v1/reports"), - "slot-0 must target the reports endpoint; got {:?}", + records[0].path.contains("/api/admin/v1/probe"), + "slot-0 must target the probe endpoint; got {:?}", records[0].path ); assert!( @@ -761,8 +687,8 @@ async fn probe_inner_nip98_challenge_then_json_200_is_authorized_and_asserts_aut records[1].method ); assert!( - records[1].path.contains("/api/admin/v1/reports"), - "slot-1 must target the reports endpoint; got {:?}", + records[1].path.contains("/api/admin/v1/probe"), + "slot-1 must target the probe endpoint; got {:?}", records[1].path ); assert_eq!( diff --git a/desktop/src-tauri/src/commands/admin/routes.rs b/desktop/src-tauri/src/commands/admin/routes.rs index 33755af53..d0c4a299e 100644 --- a/desktop/src-tauri/src/commands/admin/routes.rs +++ b/desktop/src-tauri/src/commands/admin/routes.rs @@ -43,6 +43,9 @@ impl AttachmentHash { /// are validated hex strings. #[derive(Debug)] pub enum AdminRoute { + /// Auth-mode/role/capability discovery. Requires no DB and returns role + /// `null` in token/disabled modes. + Probe, ReportsList, ReportDetail { id: uuid::Uuid, @@ -104,6 +107,7 @@ impl AdminRoute { /// Return the URL path component (not including the `/api/admin/v1` prefix). pub fn path(&self) -> String { match self { + AdminRoute::Probe => "/probe".to_string(), AdminRoute::ReportsList => "/reports".to_string(), AdminRoute::ReportDetail { id } => format!("/reports/{id}"), AdminRoute::ReportResolve { id } => format!("/reports/{id}/resolve"), @@ -245,6 +249,11 @@ mod tests { assert_eq!(AdminRoute::ReportsList.path(), "/reports"); } + #[test] + fn probe_path() { + assert_eq!(AdminRoute::Probe.path(), "/probe"); + } + #[test] fn report_detail_path() { let id = uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(); diff --git a/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx b/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx index a0e88f702..ed9015f61 100644 --- a/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx +++ b/desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx @@ -46,11 +46,15 @@ export function FeedbackTab({ generation: number; }) { const [selectedId, setSelectedId] = useState(null); + // List refresh fence: bumped when a feedback status change completes in the + // detail view, so returning to the list shows fresh status without a tab + // switch. + const [listGen, setListGen] = useState(0); const listState: AsyncState = useAsyncLoad( () => listAdminFeedback(origin), [origin, pubkey], - generation, + generation + listGen, ); if (selectedId) { @@ -61,6 +65,7 @@ export function FeedbackTab({ origin={origin} pubkey={pubkey} generation={generation} + onMutated={() => setListGen((g) => g + 1)} /> ); } @@ -383,12 +388,15 @@ export function FeedbackDetail({ generation, feedbackId, onBack, + onMutated, }: { origin: string; pubkey: string; generation: number; feedbackId: string; onBack: () => void; + /** Called after a status change completes so the parent list can refetch. */ + onMutated: () => void; }) { // Local status state: initialized from server, updated on PATCH. const [localStatus, setLocalStatus] = useState( @@ -439,7 +447,10 @@ export function FeedbackDetail({ feedbackId={feedbackId} currentStatus={localStatus} origin={origin} - onStatusChanged={setLocalStatus} + onStatusChanged={(newStatus) => { + setLocalStatus(newStatus); + onMutated(); + }} /> {attachments.length > 0 && (
diff --git a/desktop/src/features/admin-console/AdminConsolePanel.tsx b/desktop/src/features/admin-console/AdminConsolePanel.tsx index 22a4c7765..3d126572e 100644 --- a/desktop/src/features/admin-console/AdminConsolePanel.tsx +++ b/desktop/src/features/admin-console/AdminConsolePanel.tsx @@ -54,6 +54,7 @@ import { CommunityGroupedList, formatTimestamp, useAsyncLoad, + adminErrorMessage, } from "./AdminConsolePanelHelpers"; import { FeedbackTab } from "./AdminConsoleFeedbackTab"; import { StaffingTab } from "./AdminConsoleStaffingTab"; @@ -179,7 +180,6 @@ function EnforcementStateBlock({ reportId: string; onActionComplete: () => void; }) { - const [error, setError] = useState(null); const [isWorking, setIsWorking] = useState(false); const actionStatus = activeAction.status; @@ -194,7 +194,6 @@ function EnforcementStateBlock({ }; const handleCancel = async () => { - setError(null); setIsWorking(true); try { // Fence the cancel to the exact failed action the operator observed. On @@ -208,10 +207,8 @@ function EnforcementStateBlock({ } catch (e) { // A 409 means the action is no longer cancellable (already cancelled, // superseded, or past the mutation point). Reload detail rather than - // retry — the message is informational, the reload shows current state. - setError( - `Cancel rejected: ${e instanceof Error ? e.message : String(e)}`, - ); + // retry — the toast is informational, the reload shows current state. + toast.error(`Cancel rejected: ${adminErrorMessage(e)}`); onActionComplete(); } finally { setIsWorking(false); @@ -255,7 +252,6 @@ function EnforcementStateBlock({ )} )} - {error &&

{error}

}
); } @@ -285,15 +281,18 @@ function ResolveReportForm({ const [reason, setReason] = useState(""); const [expirationSecs, setExpirationSecs] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState(null); // Stable requestId per submission; regenerated on each new submit attempt. const requestIdRef = useRef(null); - const allowedActions = allowedActionsForTargetKind(report.targetKind ?? ""); + // Kick removes the target from the report's associated channel, so the + // relay rejects it (400 invalid_action_for_target) when the report carries + // no channel. Suppress it client-side rather than offer a guaranteed failure. + const allowedActions = allowedActionsForTargetKind( + report.targetKind ?? "", + ).filter((a) => a !== "kick" || report.channelId != null); const handleSubmit = async () => { if (!selectedAction) return; - setError(null); setIsSubmitting(true); // Generate a fresh requestId for this submission attempt (v4 amendment 2). @@ -321,7 +320,7 @@ function ResolveReportForm({ if (!msg.includes("409") && !msg.includes("processing")) { requestIdRef.current = null; } - setError(msg); + toast.error(adminErrorMessage(e)); } finally { setIsSubmitting(false); } @@ -405,8 +404,6 @@ function ResolveReportForm({ )} )} - - {error &&

{error}

} ); } @@ -437,7 +434,6 @@ function ReopenReportForm({ }) { const [reason, setReason] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState(null); // Stable requestId per attempt; reused on retry after a lost response. const requestIdRef = useRef(null); @@ -445,7 +441,6 @@ function ReopenReportForm({ const wasEnforced = report.actionId != null; const handleSubmit = async () => { - setError(null); setIsSubmitting(true); if (!requestIdRef.current) { @@ -467,7 +462,7 @@ function ReopenReportForm({ if (!msg.includes("409") && !msg.includes("processing")) { requestIdRef.current = null; } - setError(msg); + toast.error(adminErrorMessage(e)); } finally { setIsSubmitting(false); } @@ -513,8 +508,6 @@ function ReopenReportForm({ "Reopen report" )} - - {error &&

{error}

} ); } @@ -531,11 +524,14 @@ function ReportsTab({ generation: number; }) { const [selectedId, setSelectedId] = useState(null); + // List refresh fence: bumped whenever a mutation completes in the detail + // view, so returning to the list shows fresh status without a tab switch. + const [listGen, setListGen] = useState(0); const listState = useAsyncLoad( () => listAdminReports(origin), [origin, pubkey], - generation, + generation + listGen, ); if (selectedId) { @@ -546,6 +542,7 @@ function ReportsTab({ generation={generation} reportId={selectedId} onBack={() => setSelectedId(null)} + onMutated={() => setListGen((g) => g + 1)} /> ); } @@ -650,16 +647,26 @@ function ReportDetail({ generation, reportId, onBack, + onMutated, }: { origin: string; pubkey: string; generation: number; reportId: string; onBack: () => void; + /** Called after any mutation completes so the parent list can refetch. */ + onMutated: () => void; }) { // Resolution generation: bump to reload detail after an action completes. const [resolveGen, setResolveGen] = useState(0); + // Reload the detail AND signal the parent list on every completed mutation, + // so back-nav shows fresh status without the tab-switch workaround. + const handleMutated = () => { + setResolveGen((g) => g + 1); + onMutated(); + }; + const detailState = useAsyncLoad( () => getAdminReport(origin, reportId), [origin, pubkey, reportId], @@ -703,7 +710,7 @@ function ReportDetail({ activeAction={activeAction} origin={origin} reportId={reportId} - onActionComplete={() => setResolveGen((g) => g + 1)} + onActionComplete={handleMutated} /> )} {/* Resolve form: shown for open reports. A reopened-after-enforcement @@ -716,7 +723,7 @@ function ReportDetail({ setResolveGen((g) => g + 1)} + onResolved={handleMutated} /> )} {/* Reopen form: only for terminal (resolved/dismissed/escalated) reports */} @@ -724,7 +731,7 @@ function ReportDetail({ setResolveGen((g) => g + 1)} + onReopened={handleMutated} /> )} diff --git a/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx b/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx index e48c21aab..502117e49 100644 --- a/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx +++ b/desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx @@ -63,6 +63,31 @@ export function useAsyncLoad( return state; } +// ── Admin error message parsing ─────────────────────────────────────────── + +/** + * Extract a human-readable message from an admin mutation error. + * + * Native admin commands reject with `admin API error: {json}` where the JSON + * is the relay's error envelope (`{"error":{"code","message","requestId"}}`). + * This strips the prefix and returns the envelope's `message` field so the UI + * can surface "action kick requires the report to have an associated channel" + * instead of the raw JSON. Falls back to the raw string when the payload is + * not the expected shape (network errors, non-JSON bodies). + */ +export function adminErrorMessage(e: unknown): string { + const raw = e instanceof Error ? e.message : String(e); + const jsonStart = raw.indexOf("{"); + if (jsonStart === -1) return raw; + try { + const parsed = JSON.parse(raw.slice(jsonStart)); + const message = parsed?.error?.message; + return typeof message === "string" && message.length > 0 ? message : raw; + } catch { + return raw; + } +} + // ── Shared UI helpers ───────────────────────────────────────────────────── export function LoadingSpinner() { diff --git a/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs b/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs index 4eadfecd3..d118759cf 100644 --- a/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs +++ b/desktop/src/features/admin-console/adminConsolePanelEvents.jsdom-test.mjs @@ -93,6 +93,13 @@ toast.success = (msg) => { return 0; }; +/** @type {string[]} */ +const capturedErrorToasts = []; +toast.error = (msg) => { + capturedErrorToasts.push(String(msg)); + return 0; +}; + // ── Deferred promise helper ────────────────────────────────────────────────── function deferred() { @@ -174,6 +181,7 @@ async function settle(ms = 20) { afterEach(() => { clearIpcHandlers(); capturedToasts.length = 0; + capturedErrorToasts.length = 0; }); // ── origin-edit ────────────────────────────────────────────────────────────── @@ -2366,16 +2374,14 @@ test("reopen-409-preserves-requestId: a not-reopenable conflict reuses the same ); // No success toast on a 409. - assert.deepEqual( - capturedToasts, - [], + assert.ok( + !capturedToasts.some((m) => m.toLowerCase().includes("reopen")), `no success toast on a 409; got: ${JSON.stringify(capturedToasts)}`, ); - // The error is surfaced. - const text = container.textContent ?? ""; + // The error is surfaced via toast.error with the parsed relay message. assert.ok( - text.includes("not reopenable"), - `the 409 error message must surface; got: ${text.slice(0, 400)}`, + capturedErrorToasts.some((m) => m.includes("not reopenable")), + `the 409 error message must surface via toast.error; got: ${JSON.stringify(capturedErrorToasts)}`, ); await unmount(); @@ -2768,3 +2774,328 @@ test("feedback-severed-community: a purged-source feedback row renders in list a await unmount(); }); + +// ── D3a: kick suppressed when the report carries no channel ──────────────── + +test("kick-suppressed-when-channel-null: an event report without a channel hides the Kick action", async () => { + // Kick removes the target from the report's associated channel, so the relay + // 400s (invalid_action_for_target) when the report has no channelId. The + // resolve form must not offer an action guaranteed to fail. Other event + // actions (ban/timeout/dismiss/delete/escalate) stay available. + // + // Mutation evidence: drop the `.filter((a) => a !== "kick" || channelId + // != null)` guard → action-btn-kick renders and the null-channel assertion + // goes red. + + const origin = "https://admin.example.com"; + const pubkey = "d3".repeat(32); + + const item = { + id: "00000000-0000-0000-0000-0000000000d3", + communityId: "comm-1", + communityHost: "alpha.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + status: "open", + createdAt: "2024-06-01T12:00:00Z", + }; + const detail = { + ...item, + channelId: null, + note: null, + resolvedBy: null, + resolvedAt: null, + actionId: null, + message: null, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([item])); + setIpcHandler("admin_get_report", () => Promise.resolve(detail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + assert.ok( + container.querySelector("[data-testid='resolve-report-form']"), + "resolve form must render for an open report", + ); + assert.equal( + container.querySelector("[data-testid='action-btn-kick']"), + null, + "Kick must be suppressed when the report has no channelId", + ); + // Sibling event actions remain available — only Kick is gated. + assert.ok( + container.querySelector("[data-testid='action-btn-ban']"), + "Ban must still be offered on an event report", + ); + + await unmount(); +}); + +test("kick-offered-when-channel-set: an event report with a channel offers the Kick action", async () => { + // The paired case: when the report carries a channelId, Kick is a valid + // action (the relay can enforce it) and must be offered. + + const origin = "https://admin.example.com"; + const pubkey = "d4".repeat(32); + + const item = { + id: "00000000-0000-0000-0000-0000000000d4", + communityId: "comm-1", + communityHost: "alpha.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "event", + target: "cc", + reportType: "spam", + status: "open", + createdAt: "2024-06-01T12:00:00Z", + }; + const detail = { + ...item, + channelId: "00000000-0000-0000-0000-0000000000ff", + note: null, + resolvedBy: null, + resolvedAt: null, + actionId: null, + message: null, + }; + + setIpcHandler("admin_list_reports", () => Promise.resolve([item])); + setIpcHandler("admin_get_report", () => Promise.resolve(detail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + await openFirstReportDetail(container); + await settle(20); + + assert.ok( + container.querySelector("[data-testid='action-btn-kick']"), + "Kick must be offered when the report carries a channelId", + ); + + await unmount(); +}); + +// ── D2: lists refetch on back-nav after a mutation ───────────────────────── + +test("reports-list-refetches-on-back-after-mutation: resolving a report then navigating back shows fresh list status", async () => { + // A mutation in the detail bumps a list generation fence propagated to the + // ReportsTab, so returning to the list refetches instead of serving the + // stale cached rows (Will's tab-switch workaround). Evidence is a second + // admin_list_reports call after back-nav returning the updated status. + // + // Mutation evidence: drop the onMutated → setListGen wiring → the list + // query key never changes, admin_list_reports is called once, and the + // second-call assertion goes red. + + const origin = "https://admin.example.com"; + const pubkey = "d5".repeat(32); + + const openItem = { + id: "00000000-0000-0000-0000-0000000000d5", + communityId: "comm-1", + communityHost: "alpha.example.com", + reportEventId: "aa", + reporterPubkey: "bb", + targetKind: "pubkey", + target: "cc", + reportType: "spam", + status: "open", + createdAt: "2024-06-01T12:00:00Z", + }; + const openDetail = { + ...openItem, + channelId: null, + note: null, + resolvedBy: null, + resolvedAt: null, + actionId: null, + message: null, + }; + + // The list returns "open" first, then "dismissed" after the mutation — the + // refetch must surface the new status. + let listCalls = 0; + setIpcHandler("admin_list_reports", () => { + listCalls += 1; + return Promise.resolve([ + { ...openItem, status: listCalls === 1 ? "open" : "dismissed" }, + ]); + }); + setIpcHandler("admin_get_report", () => Promise.resolve(openDetail)); + setIpcHandler("admin_list_feedback", () => Promise.resolve([])); + setIpcHandler("admin_resolve_report", () => + Promise.resolve({ status: "dismissed" }), + ); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + + await openFirstReportDetail(container); + await settle(20); + const callsBeforeBack = listCalls; + + // Dismiss the report. + const dismissBtn = container.querySelector( + "[data-testid='action-btn-dismiss']", + ); + assert.ok(dismissBtn, "dismiss action must be present"); + await act(async () => { + fireEvent.click(dismissBtn); + await new Promise((r) => setTimeout(r, 10)); + }); + const submit = container.querySelector("[data-testid='resolve-submit-btn']"); + assert.ok( + submit, + "resolve submit button must appear after selecting dismiss", + ); + await act(async () => { + fireEvent.click(submit); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(20); + + // Navigate back to the list. + const backBtn = Array.from(container.querySelectorAll("button")).find((b) => + b.textContent?.includes("Back to reports"), + ); + assert.ok(backBtn, "back-to-reports button must be present"); + await act(async () => { + fireEvent.click(backBtn); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(20); + + assert.ok( + listCalls > callsBeforeBack, + `the list must refetch after back-nav following a mutation; before=${callsBeforeBack} after=${listCalls}`, + ); + assert.ok( + (container.textContent ?? "").includes("dismissed"), + `the refetched list must show the updated status; got: ${(container.textContent ?? "").slice(0, 400)}`, + ); + + await unmount(); +}); + +test("feedback-list-refetches-on-back-after-mutation: changing status then navigating back shows fresh list status", async () => { + // Same fence for the Feedback tab: a status change in the detail bumps the + // FeedbackTab list generation so back-nav refetches. + // + // Mutation evidence: drop the FeedbackDetail onMutated → setListGen wiring → + // admin_list_feedback is called once and the second-call assertion goes red. + + const origin = "https://admin.example.com"; + const pubkey = "d6".repeat(32); + + const summary = { + id: "00000000-0000-0000-0000-0000000000d6", + communityId: "00000000-0000-0000-0000-000000000022", + communityHost: "relay.example.com", + submitterPubkey: "submitter", + category: "bug", + bodySummary: "App crashes on startup", + status: "new", + receivedAt: "2024-05-01T09:00:05Z", + }; + const detail = { + id: summary.id, + communityId: summary.communityId, + communityHost: summary.communityHost, + eventId: "feedevent", + submitterPubkey: summary.submitterPubkey, + category: "bug", + body: "App crashes on startup — full detail", + status: "new", + tags: [], + eventCreatedAt: "2024-05-01T09:00:00Z", + receivedAt: "2024-05-01T09:00:05Z", + }; + + let listCalls = 0; + setIpcHandler("admin_list_reports", () => Promise.resolve([])); + setIpcHandler("admin_list_feedback", () => { + listCalls += 1; + return Promise.resolve([ + { ...summary, status: listCalls === 1 ? "new" : "reviewed" }, + ]); + }); + setIpcHandler("admin_get_feedback", () => Promise.resolve(detail)); + setIpcHandler("admin_patch_feedback", () => + Promise.resolve({ status: "reviewed" }), + ); + + const { container, doRender, unmount } = mountPanel({ origin, pubkey }); + await doRender(); + await settle(30); + + // Switch to the Feedback tab. + const feedbackTab = container.querySelector( + "[data-testid='admin-tab-feedback']", + ); + assert.ok(feedbackTab, "Feedback tab must be present"); + await act(async () => { + fireEvent.click(feedbackTab); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(20); + assert.equal(listCalls, 1, "feedback list is fetched once on tab open"); + + // Open the first feedback row. + const row = Array.from(container.querySelectorAll("button")).find( + (b) => + !(b.getAttribute("data-testid") ?? "").startsWith("admin-tab") && + b.textContent?.includes("App crashes"), + ); + assert.ok(row, "feedback row must be present"); + await act(async () => { + fireEvent.click(row); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(20); + + // Mark reviewed. + const reviewedBtn = container.querySelector( + "[data-testid='feedback-status-btn-reviewed']", + ); + assert.ok(reviewedBtn, "reviewed status button must be present"); + await act(async () => { + fireEvent.click(reviewedBtn); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(20); + + // Navigate back to the feedback list. + const backBtn = Array.from(container.querySelectorAll("button")).find((b) => + b.textContent?.includes("Back to feedback"), + ); + assert.ok(backBtn, "back-to-feedback button must be present"); + await act(async () => { + fireEvent.click(backBtn); + await new Promise((r) => setTimeout(r, 30)); + }); + await settle(20); + + assert.ok( + listCalls >= 2, + `the feedback list must refetch after back-nav following a status change; listCalls=${listCalls}`, + ); + assert.ok( + (container.textContent ?? "").includes("reviewed"), + `the refetched feedback list must show the updated status; got: ${(container.textContent ?? "").slice(0, 400)}`, + ); + + await unmount(); +}); diff --git a/desktop/src/features/admin-console/errorMessage.test.mjs b/desktop/src/features/admin-console/errorMessage.test.mjs new file mode 100644 index 000000000..bcb6464db --- /dev/null +++ b/desktop/src/features/admin-console/errorMessage.test.mjs @@ -0,0 +1,54 @@ +/** + * Unit tests for adminErrorMessage — the parser that turns a native admin + * mutation rejection into the human-readable text surfaced via toast.error. + * + * Native admin commands reject with `admin API error: {json}` where the JSON + * is the relay's error envelope. The parser strips the prefix and returns the + * envelope's `message`, falling back to the raw string for anything that is + * not that shape (network errors, plain strings, malformed JSON). + */ + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { adminErrorMessage } from "./AdminConsolePanelHelpers.tsx"; + +test("extracts-envelope-message: returns the relay error message, not the raw JSON", () => { + const raw = + 'admin API error: {"error":{"code":"invalid_action_for_target","message":"action kick requires the report to have an associated channel","requestId":"abc"}}'; + assert.equal( + adminErrorMessage(new Error(raw)), + "action kick requires the report to have an associated channel", + ); +}); + +test("accepts-raw-string-input: parses when passed a string rather than an Error", () => { + const raw = + 'admin API error: {"error":{"message":"report is not open (current status: processing)"}}'; + assert.equal( + adminErrorMessage(raw), + "report is not open (current status: processing)", + ); +}); + +test("falls-back-on-non-json: a plain network error returns its raw text", () => { + assert.equal( + adminErrorMessage(new Error("Failed to fetch")), + "Failed to fetch", + ); +}); + +test("falls-back-on-malformed-json: an unparseable brace payload returns raw", () => { + const raw = "admin API error: {not valid json"; + assert.equal(adminErrorMessage(new Error(raw)), raw); +}); + +test("falls-back-on-empty-message: an envelope with a blank message returns raw", () => { + const raw = 'admin API error: {"error":{"code":"x","message":""}}'; + assert.equal(adminErrorMessage(new Error(raw)), raw); +}); + +test("falls-back-on-absent-message: an envelope without a message field returns raw", () => { + const raw = 'admin API error: {"error":{"code":"internal"}}'; + assert.equal(adminErrorMessage(new Error(raw)), raw); +}); diff --git a/scripts/seed-admin-dashboard.sh b/scripts/seed-admin-dashboard.sh index e9ff10644..4d42600f7 100755 --- a/scripts/seed-admin-dashboard.sh +++ b/scripts/seed-admin-dashboard.sh @@ -128,27 +128,43 @@ BEGIN RAISE EXCEPTION 'local community is missing; run just setup first'; END IF; + -- A real channel for the failed-enforcement report below. Kick is only valid + -- on `event` reports, and the relay rejects it pre-mutation unless the report + -- carries a channel_id (FK into channels). Seeding this channel makes the + -- Kick action reachable in the UI and lets the enforcement genuinely run and + -- fail, so the Cancel & reopen recovery path is exercisable locally. + INSERT INTO channels (community_id, id, name, created_by) + VALUES ( + local_community_id, + 'c4a11e10-0000-4000-8000-000000000001', + 'seed-enforcement-channel', + decode(repeat('3b', 32), 'hex') + ) + ON CONFLICT (community_id, id) DO UPDATE SET name = EXCLUDED.name; + INSERT INTO moderation_reports ( community_id, id, report_event_id, reporter_pubkey, target_kind, - target_event_id, target_pubkey, target_blob_sha256, report_type, note, + target_event_id, target_pubkey, target_blob_sha256, channel_id, report_type, note, status, resolved_by, resolved_at, created_at ) VALUES - (local_community_id, 'a11d0000-0000-4000-8000-000000000001', decode(repeat('01', 32), 'hex'), decode(repeat('11', 32), 'hex'), 'event', decode(repeat('21', 32), 'hex'), NULL, NULL, 'spam', 'Repeated unsolicited promotion across several channels.', 'open', NULL, NULL, now() - interval '8 minutes'), - (local_community_id, 'a11d0000-0000-4000-8000-000000000002', decode(repeat('02', 32), 'hex'), decode(repeat('12', 32), 'hex'), 'pubkey', NULL, decode(repeat('22', 32), 'hex'), NULL, 'impersonation', 'Profile appears to impersonate a community organizer.', 'open', NULL, NULL, now() - interval '25 minutes'), - (local_community_id, 'a11d0000-0000-4000-8000-000000000003', decode(repeat('03', 32), 'hex'), decode(repeat('13', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('23', 32), 'hex'), 'malware', 'Attachment was flagged after download.', 'open', NULL, NULL, now() - interval '50 minutes'), - (local_community_id, 'a11d0000-0000-4000-8000-000000000004', decode(repeat('04', 32), 'hex'), decode(repeat('14', 32), 'hex'), 'event', decode(repeat('24', 32), 'hex'), NULL, NULL, 'illegal', 'Contains material that may require legal review.', 'open', NULL, NULL, now() - interval '2 hours'), - (local_community_id, 'a11d0000-0000-4000-8000-000000000005', decode(repeat('05', 32), 'hex'), decode(repeat('15', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('25', 32), 'hex'), 'nudity', NULL, 'open', NULL, NULL, now() - interval '5 hours'), - (local_community_id, 'a11d0000-0000-4000-8000-000000000006', decode(repeat('06', 32), 'hex'), decode(repeat('16', 32), 'hex'), 'pubkey', NULL, decode(repeat('26', 32), 'hex'), NULL, 'profanity', 'Repeated abusive replies from this account.', 'open', NULL, NULL, now() - interval '12 hours'), - (local_community_id, 'a11d0000-0000-4000-8000-000000000007', decode(repeat('07', 32), 'hex'), decode(repeat('17', 32), 'hex'), 'event', decode(repeat('27', 32), 'hex'), NULL, NULL, 'other', 'Does not fit a standard report category.', 'open', NULL, NULL, now() - interval '1 day'), - (local_community_id, 'a11d0000-0000-4000-8000-000000000008', decode(repeat('08', 32), 'hex'), decode(repeat('18', 32), 'hex'), 'pubkey', NULL, decode(repeat('28', 32), 'hex'), NULL, 'impersonation', 'Escalated while ownership is verified.', 'escalated', decode(repeat('38', 32), 'hex'), now() - interval '1 hour', now() - interval '2 days'), - (local_community_id, 'a11d0000-0000-4000-8000-000000000009', decode(repeat('09', 32), 'hex'), decode(repeat('19', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('29', 32), 'hex'), 'malware', 'Resolved after the attachment was removed.', 'resolved', decode(repeat('39', 32), 'hex'), now() - interval '1 day', now() - interval '3 days'), - (local_community_id, 'a11d0000-0000-4000-8000-000000000010', decode(repeat('0a', 32), 'hex'), decode(repeat('1a', 32), 'hex'), 'event', decode(repeat('2a', 32), 'hex'), NULL, NULL, 'other', 'Dismissed after reviewing the surrounding thread.', 'dismissed', decode(repeat('3a', 32), 'hex'), now() - interval '3 days', now() - interval '4 days') + (local_community_id, 'a11d0000-0000-4000-8000-000000000001', decode(repeat('01', 32), 'hex'), decode(repeat('11', 32), 'hex'), 'event', decode(repeat('21', 32), 'hex'), NULL, NULL, NULL, 'spam', 'Repeated unsolicited promotion across several channels.', 'open', NULL, NULL, now() - interval '8 minutes'), + (local_community_id, 'a11d0000-0000-4000-8000-000000000002', decode(repeat('02', 32), 'hex'), decode(repeat('12', 32), 'hex'), 'pubkey', NULL, decode(repeat('22', 32), 'hex'), NULL, NULL, 'impersonation', 'Profile appears to impersonate a community organizer.', 'open', NULL, NULL, now() - interval '25 minutes'), + (local_community_id, 'a11d0000-0000-4000-8000-000000000003', decode(repeat('03', 32), 'hex'), decode(repeat('13', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('23', 32), 'hex'), NULL, 'malware', 'Attachment was flagged after download.', 'open', NULL, NULL, now() - interval '50 minutes'), + (local_community_id, 'a11d0000-0000-4000-8000-000000000004', decode(repeat('04', 32), 'hex'), decode(repeat('14', 32), 'hex'), 'event', decode(repeat('24', 32), 'hex'), NULL, NULL, NULL, 'illegal', 'Contains material that may require legal review.', 'open', NULL, NULL, now() - interval '2 hours'), + (local_community_id, 'a11d0000-0000-4000-8000-000000000005', decode(repeat('05', 32), 'hex'), decode(repeat('15', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('25', 32), 'hex'), NULL, 'nudity', NULL, 'open', NULL, NULL, now() - interval '5 hours'), + (local_community_id, 'a11d0000-0000-4000-8000-000000000006', decode(repeat('06', 32), 'hex'), decode(repeat('16', 32), 'hex'), 'pubkey', NULL, decode(repeat('26', 32), 'hex'), NULL, NULL, 'profanity', 'Repeated abusive replies from this account.', 'open', NULL, NULL, now() - interval '12 hours'), + (local_community_id, 'a11d0000-0000-4000-8000-000000000007', decode(repeat('07', 32), 'hex'), decode(repeat('17', 32), 'hex'), 'event', decode(repeat('27', 32), 'hex'), NULL, NULL, NULL, 'other', 'Does not fit a standard report category.', 'open', NULL, NULL, now() - interval '1 day'), + (local_community_id, 'a11d0000-0000-4000-8000-000000000008', decode(repeat('08', 32), 'hex'), decode(repeat('18', 32), 'hex'), 'pubkey', NULL, decode(repeat('28', 32), 'hex'), NULL, NULL, 'impersonation', 'Escalated while ownership is verified.', 'escalated', decode(repeat('38', 32), 'hex'), now() - interval '1 hour', now() - interval '2 days'), + (local_community_id, 'a11d0000-0000-4000-8000-000000000009', decode(repeat('09', 32), 'hex'), decode(repeat('19', 32), 'hex'), 'blob', NULL, NULL, decode(repeat('29', 32), 'hex'), NULL, 'malware', 'Resolved after the attachment was removed.', 'resolved', decode(repeat('39', 32), 'hex'), now() - interval '1 day', now() - interval '3 days'), + (local_community_id, 'a11d0000-0000-4000-8000-000000000010', decode(repeat('0a', 32), 'hex'), decode(repeat('1a', 32), 'hex'), 'event', decode(repeat('2a', 32), 'hex'), NULL, NULL, NULL, 'other', 'Dismissed after reviewing the surrounding thread.', 'dismissed', decode(repeat('3a', 32), 'hex'), now() - interval '3 days', now() - interval '4 days'), + (local_community_id, 'a11d0000-0000-4000-8000-000000000011', decode(repeat('0b', 32), 'hex'), decode(repeat('1b', 32), 'hex'), 'event', decode(repeat('2b', 32), 'hex'), NULL, NULL, 'c4a11e10-0000-4000-8000-000000000001', 'spam', 'Event report in a real channel — Kick is offered and the enforcement genuinely runs and fails, exercising the Cancel & reopen recovery path.', 'open', NULL, NULL, now() - interval '3 minutes') ON CONFLICT (community_id, report_event_id) DO UPDATE SET reporter_pubkey = EXCLUDED.reporter_pubkey, target_kind = EXCLUDED.target_kind, target_event_id = EXCLUDED.target_event_id, target_pubkey = EXCLUDED.target_pubkey, target_blob_sha256 = EXCLUDED.target_blob_sha256, + channel_id = EXCLUDED.channel_id, report_type = EXCLUDED.report_type, note = EXCLUDED.note, status = EXCLUDED.status, @@ -191,4 +207,4 @@ sql="${sql//__WORKSPACE_DIAGNOSTICS_SIZE__/$(fixture_size "${workspace_diagnosti run_psql -v ON_ERROR_STOP=1 -c "${sql}" -echo "Seeded 10 moderation reports, 7 feedback entries, and 5 attachments for the local admin dashboard." +echo "Seeded 11 moderation reports, 7 feedback entries, and 5 attachments for the local admin dashboard."