From e2e007910114ddf7c5a4e93bb03f6afe13552e92 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:17:14 -0400 Subject: [PATCH] fix(security): enforce durable community ban on NIP-43 relay-admin kinds 9030-9033 (#3128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `ingest_event`'s durable write-path restriction gate exempts NIP-43 relay-admin kinds **9030–9033**, so that a *timed-out* admin keeps administrative capability. That exemption was ban-blind, and `handle_relay_admin_event` performed no restriction check of its own. A **banned** admin or owner could still add members, remove members, change member roles, and set the workspace icon by posting a signed NIP-98 request to `POST /events`. No open WebSocket required. Reported externally by **Bilal Syed** (also filed publicly as #3020 before he read `SECURITY.md`). Verified true, reproduced live, and found slightly worse than reported. Same class as BUZZ-SEC-007, which PR #1915 closed for moderation command kinds 9040–9044. That fix was never extended to the 9030 range. ## Why it worked - `handlers/ingest.rs:1639` skipped the restriction check when `is_relay_admin_kind(kind)` was true. - `handlers/relay_admin.rs` did a freshness check and a role lookup only — zero restriction reads in the file. - A ban does not remove the role: `ban_member` (`buzz-db/src/moderation.rs:314`) writes only `community_bans`, so the `relay_members` admin row survives. - The HTTP path never consulted ban state — `enforce_relay_membership` is a bare `SELECT 1 FROM relay_members`. - The ban was enforced only at the NIP-42 auth seam, which an HTTP request never crosses. **Worse than reported:** the report covered remove (9031) and icon (9033). Add (**9030**) works too, so a banned admin can *plant* new members. That matters because `moderation_authz.rs:163-170` derives "an admin cannot ban an owner or fellow admin" from `relay_members` — the very table 9030/9031 mutate. A banned admin could seed accomplices into the roster the ban was meant to stop them touching. Also of note: `moderation_authz.rs:158-165` already asserts in a comment that *"The command handler separately rejects a banned actor on every transport."* `relay_admin.rs` was the one command handler not holding that invariant. ## The fix Enforce the durable ban **inside `handle_relay_admin_event`** — the reporter's own suggested shape, and the `moderation_commands.rs:99-108` precedent. Deliberately **not** the one-token alternative of dropping `&& !is_relay_admin_kind(kind_u32)` at `ingest.rs:1639`: that would also start blocking *timed-out* admins, silently changing policy. Bans are refused; timeouts still administer, which is the entire reason the exemption exists. `handle_relay_admin_event` becomes a thin admission wrapper around an unchanged `execute_relay_admin_command` body, so no future early return inside that body can precede the check. The check therefore also necessarily precedes the freshness check. **The refusal category is part of the security contract**, so this returns a typed `RelayAdminError` rather than a string. A `blocked:` string would have kept the right wire text but returned **400** instead of **403** (`api/bridge.rs:845` vs `:858`), and would have reported a restriction-DB outage as a client error: | Variant | Ingest | Wire | HTTP | |---|---|---|---| | `Banned` | `AuthFailed` | `blocked: you are banned from this community` | **403** | | `Rejected(..)` | `Rejected` | `invalid: …` | 400 (unchanged) | | `Internal(..)` | `Internal` | `error: …` (sanitized) | **500** | ## Verification Live over real HTTP against an isolated relay, all four exempt kinds refused, DB checked after each for non-mutation: ``` [banned] 9031 remove -> 403 blocked: you are banned from this community [banned] 9030 add -> 403 blocked: you are banned from this community [banned] 9032 change role -> 403 blocked: you are banned from this community [banned] 9033 set icon -> 403 blocked: you are banned from this community ``` Victim still `member`, planted key absent, role target unchanged, icon still NULL. 9032 required a banned **owner** to be a real test, since it is owner-only. - **Mutation-tested.** The admission decision is the pure `admits_relay_admin_command(&RestrictionState)`, covered by the *default* suite. Neutering it fails `banned_actor_is_not_admitted_to_a_relay_admin_command`. The first version of this patch would have stayed green if someone deleted the check — that gap is closed. The unit test does not prove handler *wiring*; the `#[ignore]`d live E2E is what checks linkage. - **Fail-closed proven empirically**, by manual fault injection rather than assertion: renaming `community_bans.banned` out from under the running relay yields 500, no mutation, and no schema detail leaked to the client. - Negative/positive controls: timed-out admin still administers *and* is still content-write-blocked; clean admin unaffected with mutation confirmed; non-admin still gets `invalid:`/400. - Reviewed iteratively by **@Mari** over three rounds; final approval at 9/10+ on minimalness, elegance, and correctness. She also ran an independent deep regression pass on an isolated stack (odd port 44391) covering channel lifecycle, membership, messages/replies/search/edit/delete, reactions, canvas, DMs, and moderation transitions — no regressions. - `cargo fmt --all --check`, `cargo clippy -p buzz-relay --all-targets -D warnings`, `buzz-core` 229/229, `buzz-cli` 250/250, `run-tests.sh unit` all five packages green. - `buzz-relay --lib`: **756 passed / 1 failed**. The sole failure `api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo` (504 vs 200) is **pre-existing** — reproduced identically in a detached worktree at merge base `00ecf2c`. ## Notes for the reviewer - Merged `origin/main` in as a merge commit rather than rebasing, per instruction. No conflicts; the eight incoming commits touch none of the three files here. Closest neighbour is `00ecf2c` (kind:9000 NIP-29 *channel* role authz) — disjoint from this NIP-43 *relay-admin* fix. - **This does not close the class.** Two separate items remain open, deliberately excluded to keep an externally-known security fix reviewable: 1. **Command kinds dispatch before the gate.** `is_command_kind` fires at `ingest.rs:1561`, ~80 lines *before* the restriction gate, and `command_executor.rs` has no restriction read. Measured live: a banned member can still open a DM (41010 → 200). 41011/41012/30620/46030/46031 unprobed. Needs per-kind semantics enumerated first (reports allowed while banned; moderation commands allow timeouts but reject bans; ordinary writes reject both). 2. **`moderation_commands.rs` maps its own restriction-DB failure to 400, not 500**, and leaks the raw Postgres message to the client. - One correction for the public issue: its repro step 1 says `kind:9041`, which is **unban**. The ban is **9040** (`KIND_MODERATION_BAN`, `buzz-core/src/kind.rs:298`). Following the steps verbatim yields a false negative. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --------- Signed-off-by: Tyler Longwell Co-authored-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@buzz.block.builderlab.xyz> Co-authored-by: Tyler Longwell --- crates/buzz-relay/src/handlers/ingest.rs | 79 +++- crates/buzz-relay/src/handlers/relay_admin.rs | 132 +++++- .../tests/regression_relay_admin_ban_gate.rs | 377 ++++++++++++++++++ 3 files changed, 583 insertions(+), 5 deletions(-) create mode 100644 crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 497adc6c73..a30b0e714d 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -183,6 +183,19 @@ pub enum IngestError { Internal(String), } +fn map_relay_admin_error(error: super::relay_admin::RelayAdminError) -> IngestError { + use super::relay_admin::RelayAdminError; + match error { + // Same wire prefix and HTTP status (403) as every other durable + // restriction refusal — see the write-path gate below and `auth.rs`. + RelayAdminError::Banned => { + IngestError::AuthFailed("blocked: you are banned from this community".to_string()) + } + RelayAdminError::Rejected(reason) => IngestError::Rejected(format!("invalid: {reason}")), + RelayAdminError::Internal(reason) => IngestError::Internal(format!("error: {reason}")), + } +} + fn map_push_accept_error(error: super::push_lease::AcceptError) -> IngestError { match error { super::push_lease::AcceptError::Validation(reason) => { @@ -1624,7 +1637,10 @@ async fn ingest_event_inner( // is the durable backstop the fan-out's best-effort delivery relies on. // Moderation commands enforce bans inside their handler and remain exempt // here only so timeouts do not disarm the tool used to lift them. Relay-admin - // commands retain their separate authorization policy. + // commands (9030–9033) are exempt for the same reason — a timed-out admin + // must still be able to administer the roster — and likewise enforce the + // durable ban inside `relay_admin::handle_relay_admin_event`. Any kind added + // to this exemption owes the same handler-local ban check. // // Scope: this gate checks the *authoring* pubkey only, with no NIP-OA // owner→agent cascade. That cascade lives at the auth seam for bans, where @@ -1831,10 +1847,13 @@ async fn ingest_event_inner( } // Handled directly — these mutate relay_members and do NOT get stored. + // The handler enforces the durable community ban itself: the write-path + // gate above exempts relay-admin kinds so timed-out admins keep their + // administrative capability, which leaves bans to the handler. if is_relay_admin_kind(event.kind.as_u16() as u32) { crate::handlers::relay_admin::handle_relay_admin_event(tenant, state, &event) .await - .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + .map_err(map_relay_admin_error)?; return Ok(IngestResult { event_id: event_id_hex, accepted: true, @@ -2542,6 +2561,62 @@ mod tests { }; use nostr::{EventBuilder, Kind}; + /// A banned relay admin must be refused with the same wire prefix and + /// transport status as every other durable-restriction refusal: + /// `blocked:` and (via `bridge.rs`'s `AuthFailed` arm) HTTP 403 — never + /// `invalid:`/400, which reads as "your request was malformed" and lets a + /// client retry-loop against an authorization decision. + #[test] + fn relay_admin_ban_maps_to_blocked_auth_failure() { + let mapped = map_relay_admin_error(super::super::relay_admin::RelayAdminError::Banned); + match mapped { + IngestError::AuthFailed(msg) => { + assert_eq!(msg, "blocked: you are banned from this community"); + } + other => panic!("banned admin must map to AuthFailed (HTTP 403), got {other:?}"), + } + } + + /// Validation/authorization failures keep the pre-existing `invalid:` + /// prefix and 400 status — this is the arm the whole 9030-series relied on + /// before the ban category existed, so it must not regress. + #[test] + fn relay_admin_rejection_keeps_invalid_prefix() { + let mapped = map_relay_admin_error(super::super::relay_admin::RelayAdminError::Rejected( + "actor not authorized: must be admin or owner".to_string(), + )); + match mapped { + IngestError::Rejected(msg) => { + assert_eq!( + msg, "invalid: actor not authorized: must be admin or owner", + "existing relay-admin rejections must keep their exact wire text" + ); + } + other => panic!("validation failure must map to Rejected, got {other:?}"), + } + } + + /// A restriction-lookup outage is a server fault, not a client one. It + /// must fail closed as `error:`/500 so a Postgres blip can neither admit a + /// banned admin nor be reported to an innocent one as a bad request. + #[test] + fn relay_admin_internal_maps_to_error_not_client_fault() { + let mapped = map_relay_admin_error(super::super::relay_admin::RelayAdminError::Internal( + "internal error checking restriction state: pool timed out".to_string(), + )); + match mapped { + IngestError::Internal(msg) => { + assert!( + msg.starts_with("error: "), + "internal failures need the `error:` NIP-01 prefix, got {msg:?}" + ); + } + other => { + panic!("restriction DB failure must map to Internal (HTTP 500), got {other:?}") + } + } + } + #[derive(Debug, Default)] struct VecTracer { steps: Mutex>, diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 840d9dcfe5..3f58a9c2aa 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -94,8 +94,92 @@ fn validate_workspace_icon(icon: &str) -> Result<(), String> { Ok(()) } +/// A relay-admin command failure, carrying the *category* of the failure so +/// the ingest seam can map it to the right NIP-01 prefix and HTTP status. +/// +/// The category is part of the security contract, not cosmetics: a ban must +/// surface as `blocked:` / HTTP 403 (like every other durable-restriction +/// refusal), and a restriction-lookup outage must surface as a 500 rather than +/// a client-side 400 that reads as "your request was malformed". Mirrors +/// [`super::push_lease::AcceptError`] and its `map_push_accept_error` seam. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum RelayAdminError { + /// Sender is under a durable community ban — `blocked:` / HTTP 403. + Banned, + /// Legacy command rejection — `invalid:` / HTTP 400. This is whatever + /// [`execute_relay_admin_command`] returned as a `String`, which is mostly + /// validation and authorization but also still includes that function's own + /// DB failures. Categorizing those is deliberately out of scope for the ban + /// fix, so this arm claims no stronger invariant than "the command body said + /// no". + Rejected(String), + /// Admission could not be decided because the restriction lookup failed — + /// `error:` / HTTP 500. + Internal(String), +} + +/// Decide whether a durable restriction state admits a relay-admin command. +/// +/// Ban only, deliberately: a timeout is a write-block on *content*, and +/// `ingest_event` exempts relay-admin kinds from its durable write-path gate +/// precisely so restricted-but-not-banned admins retain their administrative +/// capability. Mirrors `moderation_commands::ensure_actor_not_banned`. +/// +/// Split out as a pure function so the admission rule itself is unit-testable +/// without a live relay: the end-to-end HTTP test proves the transport, this +/// proves the decision. +fn admits_relay_admin_command( + restriction: &buzz_db::moderation::RestrictionState, +) -> Result<(), RelayAdminError> { + if restriction.banned { + return Err(RelayAdminError::Banned); + } + Ok(()) +} + /// Validate and execute a relay admin command (kinds 9030–9033). /// +/// Admission: rejects a sender under a durable community ban before any +/// command runs. The command itself is executed by +/// [`execute_relay_admin_command`]. +/// +/// Returns `Ok(())` on success, or a categorized [`RelayAdminError`]. +pub(super) async fn handle_relay_admin_event( + tenant: &TenantContext, + state: &Arc, + event: &Event, +) -> Result<(), RelayAdminError> { + // A ban is an admission boundary, not only a WebSocket-auth check. HTTP + // NIP-98 requests and already-authenticated sockets reach this handler + // without passing through a fresh NIP-42 challenge, and `ingest_event` + // exempts relay-admin kinds from its durable write-path gate so a *timed + // out* admin can still administer the roster. That exemption is ban-blind, + // so the ban must be enforced here or not at all: a banned admin otherwise + // keeps mutating `relay_members` — the very table `moderation_authz` + // derives moderator capability from — until someone manually deletes the + // row. Mirrors `moderation_commands.rs`, which defends the same boundary + // for 9040–9044, and holds that file's stated invariant that a direct + // command handler rejects a banned actor on every transport. + // + // This gate wraps execution rather than opening it so no future early + // return inside the command body can precede it. + let restriction = state + .db + .moderation_restriction_state(tenant.community(), &event.pubkey.to_bytes()) + .await + // Fail closed: a DB blip must never admit a banned admin. + .map_err(|e| { + RelayAdminError::Internal(format!("internal error checking restriction state: {e}")) + })?; + admits_relay_admin_command(&restriction)?; + + execute_relay_admin_command(tenant, state, event) + .await + .map_err(RelayAdminError::Rejected) +} + +/// Execute an already-admitted relay admin command. +/// /// The handler: /// 1. Extracts the target pubkey from the `["p", ...]` tag. /// 2. Extracts the role from the `["role", ...]` tag (kinds 9030 and 9032). @@ -103,9 +187,11 @@ fn validate_workspace_icon(icon: &str) -> Result<(), String> { /// 4. Enforces the permission matrix. /// 5. Applies the change via the DB. /// -/// Returns `Ok(())` on success. Returns `Err(msg)` — where `msg` is a -/// human-readable rejection reason — on any validation failure. -pub async fn handle_relay_admin_event( +/// Returns `Ok(())` on success. Returns `Err(msg)` — where `msg` is the +/// legacy rejection reason — on any failure. This body does not distinguish +/// validation failures from execution DB failures; both surface as `Err(msg)` +/// and are categorised by the caller as [`RelayAdminError::Rejected`]. +async fn execute_relay_admin_command( tenant: &TenantContext, state: &Arc, event: &Event, @@ -349,6 +435,46 @@ mod tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; + /// The vulnerability this file's ban gate closes: `ingest_event` exempts + /// relay-admin kinds 9030–9033 from its durable write-path restriction + /// gate, so a **banned** admin could add/remove relay members and change + /// the workspace icon over signed NIP-98 `POST /events`. Deleting the + /// admission check must fail here, in the default (non-ignored) suite. + #[test] + fn banned_actor_is_not_admitted_to_a_relay_admin_command() { + let banned = buzz_db::moderation::RestrictionState { + banned: true, + muted_until: None, + }; + assert_eq!( + admits_relay_admin_command(&banned), + Err(RelayAdminError::Banned), + "a durably banned admin must never reach a relay-admin command" + ); + } + + /// The counter-invariant, and the reason the ingest exemption exists at + /// all: a timeout restricts *content* writes, not administrative + /// capability. Widening this gate to timeouts would silently change policy. + #[test] + fn timed_out_actor_is_still_admitted() { + let timed_out = buzz_db::moderation::RestrictionState { + banned: false, + muted_until: Some(chrono::Utc::now() + chrono::Duration::minutes(5)), + }; + assert!( + admits_relay_admin_command(&timed_out).is_ok(), + "a timed-out admin must still administer the roster" + ); + } + + #[test] + fn unrestricted_actor_is_admitted() { + assert!( + admits_relay_admin_command(&buzz_db::moderation::RestrictionState::default()).is_ok() + ); + } + /// Build a minimal signed Event with the given kind and tags. /// The pubkey will be randomly generated — sufficient for tag extraction tests. fn make_test_event(kind: u16, tags: Vec>) -> Event { diff --git a/crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs b/crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs new file mode 100644 index 0000000000..c37be8ca40 --- /dev/null +++ b/crates/buzz-test-client/tests/regression_relay_admin_ban_gate.rs @@ -0,0 +1,377 @@ +//! Regression test for the NIP-43 relay-admin durable-ban bypass +//! (BUZZ-SEC-007 class, reported 2026-07-27). +//! +//! `ingest_event` exempts relay-admin kinds 9030-9033 from its durable +//! write-path restriction gate so a *timed out* admin keeps its administrative +//! capability. That exemption was ban-blind, so a **banned** admin could still +//! add/remove relay members and change the workspace icon via signed NIP-98 +//! `POST /events`. The ban is now enforced inside +//! `relay_admin::handle_relay_admin_event`; this test pins both halves of that +//! contract — bans refused, timeouts still admitted. +//! +//! Requires a running relay and its Postgres. Ignored by default: +//! REPRO_RELAY_HTTP=http://localhost:3999 REPRO_HOST=localhost:3999 \ +//! DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz_relay_admin_regression \ +//! cargo test -p buzz-test-client --test regression_relay_admin_ban_gate \ +//! -- --ignored --nocapture + +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use nostr::{EventBuilder, Keys, Kind, Tag}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +fn http_base() -> String { + std::env::var("REPRO_RELAY_HTTP").unwrap_or_else(|_| "http://localhost:3999".into()) +} +fn host() -> String { + std::env::var("REPRO_HOST").unwrap_or_else(|_| "localhost:3999".into()) +} +fn db_url() -> String { + std::env::var("DATABASE_URL").expect("DATABASE_URL required") +} + +fn sha256_hex(b: &[u8]) -> String { + hex::encode(Sha256::digest(b)) +} + +fn nip98(keys: &Keys, url: &str, body: &str) -> String { + let ev = EventBuilder::new(Kind::Custom(27_235), "") + .tags(vec![ + Tag::parse(["u", url]).unwrap(), + Tag::parse(["method", "POST"]).unwrap(), + Tag::parse(["payload", &sha256_hex(body.as_bytes())]).unwrap(), + Tag::parse(["nonce", &Uuid::new_v4().to_string()]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap(); + format!( + "Nostr {}", + BASE64.encode(serde_json::to_string(&ev).unwrap()) + ) +} + +async fn post_event(keys: &Keys, event: &nostr::Event) -> (u16, String) { + let body = serde_json::to_string(event).unwrap(); + let signed_url = format!("http://{}/events", host()); + let r = reqwest::Client::new() + .post(format!("{}/events", http_base())) + .header("Host", host()) + .header("Content-Type", "application/json") + .header("Authorization", nip98(keys, &signed_url, &body)) + .body(body) + .send() + .await + .expect("POST /events"); + let status = r.status().as_u16(); + (status, r.text().await.unwrap_or_default()) +} + +fn signed(keys: &Keys, kind: u16, tags: Vec) -> nostr::Event { + EventBuilder::new(Kind::Custom(kind), "") + .tags(tags) + .sign_with_keys(keys) + .unwrap() +} + +async fn pool() -> sqlx::Pool { + sqlx::postgres::PgPoolOptions::new() + .max_connections(2) + .connect(&db_url()) + .await + .expect("connect Postgres") +} + +async fn community_id(p: &sqlx::Pool) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO communities (id, host) VALUES ($1, $2) ON CONFLICT (lower(host)) DO NOTHING", + ) + .bind(id) + .bind(host()) + .execute(p) + .await + .unwrap(); + sqlx::query_scalar("SELECT id FROM communities WHERE lower(host) = lower($1)") + .bind(host()) + .fetch_one(p) + .await + .unwrap() +} + +async fn seed(p: &sqlx::Pool, cid: Uuid, keys: &Keys, role: &str) { + sqlx::query("INSERT INTO users (community_id, pubkey) VALUES ($1, $2) ON CONFLICT DO NOTHING") + .bind(cid) + .bind(keys.public_key().to_bytes().to_vec()) + .execute(p) + .await + .ok(); + sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role, added_by) VALUES ($1,$2,$3,NULL) \ + ON CONFLICT (community_id, pubkey) DO UPDATE SET role = $3, updated_at = now()", + ) + .bind(cid) + .bind(keys.public_key().to_hex()) + .bind(role) + .execute(p) + .await + .unwrap(); +} + +/// Post-fix regression bar. +/// +/// Asserts the full contract rather than just "the exploit stopped": +/// - banned admin: 403 + exact `blocked:` prefix on 9030/9031/9033, and a +/// banned *owner* likewise on 9032 (owner-only kind), covering all four +/// exempt kinds, +/// - no roster, role, or icon mutation from any of those attempts, +/// - a *timed-out* admin still reaches relay-admin authorization (the ingest +/// exemption's whole purpose — the fix must not silently widen to timeouts), +/// - an unrestricted admin's behaviour is unchanged, mutation included. +#[tokio::test] +#[ignore] +async fn banned_admin_is_refused_but_timed_out_admin_still_administers() { + let p = pool().await; + let cid = community_id(&p).await; + + let owner = Keys::generate(); + let banned_owner = Keys::generate(); + let banned_admin = Keys::generate(); + let timed_out_admin = Keys::generate(); + let good_admin = Keys::generate(); + let victim = Keys::generate(); + let victim2 = Keys::generate(); + let victim3 = Keys::generate(); + let role_target = Keys::generate(); + // Retained (not generated inline) so the 9030 attempt can be checked for + // absence afterward — a planted member is the mutation that attempt buys. + let planted = Keys::generate(); + for (k, r) in [ + (&owner, "owner"), + (&banned_owner, "owner"), + (&banned_admin, "admin"), + (&timed_out_admin, "admin"), + (&good_admin, "admin"), + (&victim, "member"), + (&victim2, "member"), + (&victim3, "member"), + (&role_target, "member"), + ] { + seed(&p, cid, k, r).await; + } + + // Owner bans one admin and times out another, through the real 9040/9042 + // command path. + let (s, _) = post_event( + &owner, + &signed( + &owner, + 9040, + vec![Tag::parse(["p", &banned_admin.public_key().to_hex()]).unwrap()], + ), + ) + .await; + assert_eq!(s, 200, "ban must land"); + let expiry = (chrono_now() + 3600).to_string(); + let (s, b) = post_event( + &owner, + &signed( + &owner, + 9042, + vec![ + Tag::parse(["p", &timed_out_admin.public_key().to_hex()]).unwrap(), + Tag::parse(["expiration", &expiry]).unwrap(), + ], + ), + ) + .await; + assert_eq!(s, 200, "timeout must land: {b}"); + + // 9032 is owner-only, so its banned case needs a banned *owner*. Whether + // one owner may 9040 another is a moderation-policy question independent of + // this fix, so the ban row is seeded directly to keep the test pinned to + // the admission gate. + sqlx::query( + "INSERT INTO community_bans (community_id, pubkey, banned, actor_pubkey) \ + VALUES ($1,$2,true,$3) \ + ON CONFLICT (community_id, pubkey) DO UPDATE SET banned = true", + ) + .bind(cid) + .bind(banned_owner.public_key().to_bytes().to_vec()) + .bind(owner.public_key().to_bytes().to_vec()) + .execute(&p) + .await + .unwrap(); + + // ── Banned actors: every relay-admin kind must be 403 + `blocked:`. ── + for (actor, kind, tags, label) in [ + ( + &banned_admin, + 9031u16, + vec![Tag::parse(["p", &victim.public_key().to_hex()]).unwrap()], + "9031 remove", + ), + ( + &banned_admin, + 9030u16, + vec![ + Tag::parse(["p", &planted.public_key().to_hex()]).unwrap(), + Tag::parse(["role", "member"]).unwrap(), + ], + "9030 add", + ), + ( + &banned_admin, + 9033u16, + vec![Tag::parse(["icon", "https://evil.example/pwned.png"]).unwrap()], + "9033 icon", + ), + ( + &banned_owner, + 9032u16, + vec![ + Tag::parse(["p", &role_target.public_key().to_hex()]).unwrap(), + Tag::parse(["role", "admin"]).unwrap(), + ], + "9032 change role", + ), + ] { + let (st, body) = post_event(actor, &signed(actor, kind, tags)).await; + println!("[banned] {label} -> {st} {body}"); + assert_eq!( + st, 403, + "{label}: banned actor must get 403, got {st} {body}" + ); + let msg: serde_json::Value = serde_json::from_str(&body).unwrap_or_default(); + let text = msg.get("error").and_then(|v| v.as_str()).unwrap_or(&body); + assert_eq!( + text, "blocked: you are banned from this community", + "{label}: must carry the exact `blocked:` wire contract" + ); + } + + // No mutation from any banned attempt. + let role_of = |k: &Keys| { + let hex = k.public_key().to_hex(); + let p = p.clone(); + async move { + sqlx::query_scalar::<_, String>( + "SELECT role FROM relay_members WHERE community_id=$1 AND pubkey=$2", + ) + .bind(cid) + .bind(hex) + .fetch_optional(&p) + .await + .unwrap() + } + }; + assert_eq!( + role_of(&victim).await.as_deref(), + Some("member"), + "9031: banned admin must not remove a member" + ); + assert_eq!( + role_of(&planted).await, + None, + "9030: banned admin must not plant a new member" + ); + assert_eq!( + role_of(&role_target).await.as_deref(), + Some("member"), + "9032: banned owner must not change a member's role" + ); + let icon: Option = sqlx::query_scalar("SELECT icon FROM communities WHERE id=$1") + .bind(cid) + .fetch_one(&p) + .await + .unwrap(); + assert!( + icon.is_none(), + "9033: banned admin must not change the workspace icon, got {icon:?}" + ); + + // ── Timed-out admin: still administers (ingest exemption preserved). ── + let (ts, tb) = post_event( + &timed_out_admin, + &signed( + &timed_out_admin, + 9031, + vec![Tag::parse(["p", &victim2.public_key().to_hex()]).unwrap()], + ), + ) + .await; + println!("[timed-out] 9031 remove -> {ts} {tb}"); + assert_eq!( + ts, 200, + "timed-out admin must still administer the roster: {tb}" + ); + assert_eq!( + role_of(&victim2).await, + None, + "timed-out admin's removal must take effect" + ); + + // Control: the same timed-out admin is still write-blocked for content. + let (cs, cb) = post_event( + &timed_out_admin, + &EventBuilder::new(Kind::Custom(9), "x") + .tags(vec![Tag::parse(["h", &Uuid::new_v4().to_string()]).unwrap()]) + .sign_with_keys(&timed_out_admin) + .unwrap(), + ) + .await; + println!("[timed-out] control kind:9 -> {cs} {cb}"); + assert_ne!( + cs, 200, + "timed-out admin must still be write-blocked for content" + ); + + // ── Unrestricted admin: behaviour unchanged, mutation included. ── + let (gs, gb) = post_event( + &good_admin, + &signed( + &good_admin, + 9031, + vec![Tag::parse(["p", &victim3.public_key().to_hex()]).unwrap()], + ), + ) + .await; + println!("[clean] 9031 remove -> {gs} {gb}"); + assert_eq!(gs, 200, "unrestricted admin must be unaffected: {gb}"); + assert_eq!( + role_of(&victim3).await, + None, + "unrestricted admin's removal must actually take effect" + ); + + // ── Unchanged rejection contract: non-admin still gets `invalid:`/400. ── + let nobody = Keys::generate(); + seed(&p, cid, &nobody, "member").await; + let (ns, nb) = post_event( + &nobody, + &signed( + &nobody, + 9031, + vec![Tag::parse(["p", &victim.public_key().to_hex()]).unwrap()], + ), + ) + .await; + println!("[non-admin] 9031 -> {ns} {nb}"); + assert_eq!( + ns, 400, + "a plain member's 9031 must stay a 400 validation reject" + ); + assert!( + nb.contains("invalid: actor not authorized"), + "non-admin rejection must keep its `invalid:` prefix, got {nb}" + ); + + println!("\nALL INVARIANTS HELD"); +} + +fn chrono_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +}