fix(security): enforce durable community ban on NIP-43 relay-admin kinds 9030-9033 (#3128)

## 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 <tlongwell@block.xyz>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>

---------

Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
Tyler
2026-07-27 12:17:14 -04:00
committed by GitHub
parent fe84274d21
commit e2e0079101
3 changed files with 583 additions and 5 deletions

View File

@@ -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<Vec<TraceStep>>,

View File

@@ -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<AppState>,
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<AppState>,
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<Vec<&'static str>>) -> Event {

View File

@@ -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<Tag>) -> nostr::Event {
EventBuilder::new(Kind::Custom(kind), "")
.tags(tags)
.sign_with_keys(keys)
.unwrap()
}
async fn pool() -> sqlx::Pool<sqlx::Postgres> {
sqlx::postgres::PgPoolOptions::new()
.max_connections(2)
.connect(&db_url())
.await
.expect("connect Postgres")
}
async fn community_id(p: &sqlx::Pool<sqlx::Postgres>) -> 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<sqlx::Postgres>, 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<String> = 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)
}