diff --git a/.env.example b/.env.example
index 3b7aca356d..d3deb6f536 100644
--- a/.env.example
+++ b/.env.example
@@ -119,9 +119,16 @@ BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns
# the console without manual URL entry.
# RELAY_OPERATOR_PUBKEYS=<64-char hex pubkey>[,<64-char hex pubkey>...]
#
-# Option B — BUZZ_ADMIN_AUTH=disabled (network-layer auth only):
-# Set only when the admin API is already protected at the network layer
-# (VPN, private ingress). The relay logs a WARN on every startup.
+# Option B — BUZZ_ADMIN_AUTH=disabled (read-only, no authentication):
+# Anyone who can reach the relay can read, in every community: reports (with
+# reporter notes and reported message content, deleted included), feedback and
+# attachments, bans and timeouts with reasons, the community directory, member
+# search and profiles (not staff status), and any stored message by ID.
+# Writes and staffing are refused.
+# Only people allowed to see all of that should be able to reach it. The admin
+# API is on the relay's main listener and its hostname check uses the caller's
+# Host header, so the whole relay port must be private, not just the admin
+# hostname. The relay logs a WARN on every startup.
# `just admin` defaults to this mode for local review.
#
# Directory holding the built dashboard assets (`pnpm -C admin-web build`).
diff --git a/Justfile b/Justfile
index f01458f449..67f5c391be 100644
--- a/Justfile
+++ b/Justfile
@@ -675,13 +675,24 @@ admin: bootstrap _ensure-migrations
pnpm -C admin-web build
export BUZZ_ADMIN_HOST="${BUZZ_ADMIN_HOST:-admin.localhost:3000}"
export BUZZ_ADMIN_WEB_DIR="${BUZZ_ADMIN_WEB_DIR:-{{justfile_directory()}}/admin-web/dist}"
- # Default to disabled auth locally: localhost is the network boundary and a
- # NIP-07 signer extension can't be assumed in dev. Override per run with
- # BUZZ_ADMIN_AUTH=nip98 (plus RELAY_OPERATOR_PUBKEYS or RELAY_OWNER_PUBKEY)
- # to exercise the authenticated path.
+ # Default to disabled auth locally: a NIP-07 signer extension can't be
+ # assumed in dev. Disabled mode serves every moderation read (reports,
+ # feedback, restrictions, community directory, member profiles, any stored
+ # message by ID) to anyone who can reach the relay port and refuses writes
+ # and staffing, so it binds the relay to 127.0.0.1 (port from .env)
+ # unless the mode is exactly nip98: a padded or misspelled value stays
+ # local. Override per run with BUZZ_ADMIN_AUTH=nip98 (plus
+ # RELAY_OPERATOR_PUBKEYS or RELAY_OWNER_PUBKEY) to exercise the
+ # authenticated path; that keeps BUZZ_BIND_ADDR as configured.
export BUZZ_ADMIN_AUTH="${BUZZ_ADMIN_AUTH:-disabled}"
+ if [[ "$BUZZ_ADMIN_AUTH" != nip98 ]]; then
+ bind_addr="${BUZZ_BIND_ADDR:-0.0.0.0:3000}"
+ relay_port="${bind_addr##*:}"; [[ -n "$relay_port" ]] || relay_port=3000
+ export BUZZ_BIND_ADDR="127.0.0.1:${relay_port}"
+ fi
echo "Admin dashboard: http://${BUZZ_ADMIN_HOST}/reports"
echo "Auth mode: ${BUZZ_ADMIN_AUTH} (set BUZZ_ADMIN_AUTH=nip98 to require a signed operator)"
+ echo "Relay bind: ${BUZZ_BIND_ADDR:-0.0.0.0:3000}"
cargo run -p buzz-relay
# Seed deterministic reports and product feedback for local admin dashboard review
diff --git a/admin-web/src/App.tsx b/admin-web/src/App.tsx
index bc22f61561..980ccf68c6 100644
--- a/admin-web/src/App.tsx
+++ b/admin-web/src/App.tsx
@@ -199,8 +199,7 @@ function ReportDetail({ id, authMode }: { id: string; authMode: AuthMode }) {
) : (
- Message content is unavailable. It may have expired or
- been removed from event storage.
+ Message content is unavailable.
)}
diff --git a/crates/buzz-db/src/store/admin_moderation.rs b/crates/buzz-db/src/store/admin_moderation.rs
index a6a92c6a9a..1c7de25cc0 100644
--- a/crates/buzz-db/src/store/admin_moderation.rs
+++ b/crates/buzz-db/src/store/admin_moderation.rs
@@ -4,6 +4,7 @@
//! [`CommunityId`](buzz_core::CommunityId). Keep ordinary moderation reads in
//! [`crate::moderation`] tenant-fenced.
+use buzz_core::kind::{AUTHOR_ONLY_KINDS, P_GATED_KINDS, RESULT_GATED_KINDS, SHARED_GATED_KINDS};
use buzz_datastore_tracing::datastore_span;
use chrono::{DateTime, Utc};
use serde::Serialize;
@@ -16,6 +17,20 @@ use crate::Db;
/// Maximum rows accepted by one admin query.
pub const MAX_PAGE_SIZE: i64 = 200;
+/// Kinds no admin read may reveal: author-only, result-gated, `#p`-gated and
+/// shared-gated events. The whole shared-gated set is hidden, shared or not.
+/// Moderators can still act on these kinds (direct delete by event ID, report
+/// enforcement); they just cannot read them, so admin reads treat them as absent.
+fn admin_hidden_kinds() -> Vec {
+ AUTHOR_ONLY_KINDS
+ .iter()
+ .chain(RESULT_GATED_KINDS)
+ .chain(P_GATED_KINDS)
+ .chain(SHARED_GATED_KINDS)
+ .map(|&kind| kind as i32)
+ .collect()
+}
+
fn bounded_limit(limit: i64) -> i64 {
limit.clamp(1, MAX_PAGE_SIZE)
}
@@ -209,6 +224,7 @@ pub async fn list_reports(
WHERE r.target_kind = 'event'
AND e.community_id = r.community_id
AND e.id = r.target_event_id
+ AND e.kind <> ALL($10)
ORDER BY e.created_at DESC
LIMIT 1
) target ON TRUE
@@ -232,13 +248,19 @@ pub async fn list_reports(
.bind(cursor_time)
.bind(cursor_id)
.bind(bounded_limit(limit))
+ .bind(admin_hidden_kinds())
.fetch_all(pool)
.await?;
rows.into_iter().map(row_to_report).collect()
}
/// Fetch one report globally by its row id, including its event target content.
-pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result> {
+/// Targets whose kind is in `hidden_kinds` come back as `message: None`.
+async fn get_report(
+ pool: &PgPool,
+ report_id: Uuid,
+ hidden_kinds: &[i32],
+) -> Result > {
let row = sqlx::query(
r#"
SELECT r.id, r.community_id, c.host AS community_host,
@@ -271,6 +293,7 @@ pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result ALL($2)
ORDER BY e.created_at DESC
LIMIT 1
) target ON TRUE
@@ -290,6 +313,7 @@ pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result Result {
})
}
+/// Active community row in the admin community directory.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct AdminCommunity {
+ /// Community identifier.
+ pub id: Uuid,
+ /// Served host.
+ pub host: String,
+ /// Workspace icon URL, when set.
+ pub icon: Option,
+}
+
+/// List active communities whose host starts with `prefix` (case-insensitive,
+/// matched literally), ordered by `lower(host)` and resumed after the
+/// lowercased host `after`. `lower(host)` is unique, so it alone is a total
+/// order. Uses the tenant binder's liveness rule, so every row can be bound.
+pub async fn list_communities(
+ pool: &PgPool,
+ prefix: &str,
+ after: Option<&str>,
+ limit: i64,
+) -> Result> {
+ let rows = sqlx::query(
+ r#"
+ SELECT id, host, icon
+ FROM communities
+ WHERE archived_at IS NULL
+ AND deleted_at IS NULL
+ AND deletion_state = 'active'
+ AND lower(host) LIKE lower($1) || '%' ESCAPE '\'
+ AND lower(host) > COALESCE($2, '')
+ ORDER BY lower(host)
+ LIMIT $3
+ "#,
+ )
+ .bind(crate::user::escape_like(prefix))
+ .bind(after)
+ .bind(bounded_limit(limit))
+ .fetch_all(pool)
+ .await?;
+ rows.into_iter()
+ .map(|row| {
+ Ok(AdminCommunity {
+ id: row.try_get("id")?,
+ host: row.try_get("host")?,
+ icon: row.try_get("icon")?,
+ })
+ })
+ .collect()
+}
+
+/// One stored event, read inside a single community for a delete preview.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct AdminEventPreview {
+ /// Event id (hex).
+ pub id: String,
+ /// Author public key (hex).
+ pub author_pubkey: String,
+ /// Event kind.
+ pub kind: i32,
+ /// Event content.
+ pub content: String,
+ /// Timestamp signed into the event.
+ pub created_at: DateTime,
+ /// Soft-deletion time, when deleted.
+ pub deleted_at: Option>,
+ /// Channel the event belongs to, if any.
+ pub channel_id: Option,
+}
+
+/// Fetch event `id` from `community` only (same lookup as the report detail).
+///
+/// Kinds in [`admin_hidden_kinds`] are never returned: the relay must not
+/// reveal that such an event exists to anyone but its permitted readers, so a
+/// matching row is reported as absent, exactly as the report list and detail
+/// treat a hidden target.
+pub async fn get_event_preview(
+ pool: &PgPool,
+ community_id: Uuid,
+ id: &[u8],
+) -> Result> {
+ let row = sqlx::query(
+ r#"
+ SELECT id, pubkey, kind, content, created_at, deleted_at, channel_id
+ FROM events
+ WHERE community_id = $1 AND id = $2 AND kind <> ALL($3)
+ ORDER BY created_at DESC
+ LIMIT 1
+ "#,
+ )
+ .bind(community_id)
+ .bind(id)
+ .bind(admin_hidden_kinds())
+ .fetch_optional(pool)
+ .await?;
+ row.map(|row| {
+ Ok(AdminEventPreview {
+ id: hex::encode(row.try_get::, _>("id")?),
+ author_pubkey: hex::encode(row.try_get::, _>("pubkey")?),
+ kind: row.try_get("kind")?,
+ content: row.try_get("content")?,
+ created_at: row.try_get("created_at")?,
+ deleted_at: row.try_get("deleted_at")?,
+ channel_id: row.try_get("channel_id")?,
+ })
+ })
+ .transpose()
+}
+
impl Db {
/// List reports for the deployment-global read-only admin plane.
#[allow(clippy::too_many_arguments)]
@@ -453,10 +587,22 @@ impl Db {
.await
}
- /// Fetch one report for the deployment-global read-only admin plane.
+ /// Fetch one report for any admin HTTP response. Hidden-kind targets come
+ /// back as `message: None`, exactly like a missing event.
#[datastore_span(name = "admin_get_report", system = "postgresql")]
pub async fn admin_get_report(&self, id: Uuid) -> Result> {
- get_report(&self.pool, id).await
+ get_report(&self.pool, id, &admin_hidden_kinds()).await
+ }
+
+ /// Fetch one report with its target unfiltered, for report enforcement and
+ /// action recovery only, which need the real target author. Never serialize
+ /// this result into an HTTP response; use [`Self::admin_get_report`].
+ #[datastore_span(name = "admin_get_report_for_enforcement", system = "postgresql")]
+ pub async fn admin_get_report_for_enforcement(
+ &self,
+ id: Uuid,
+ ) -> Result > {
+ get_report(&self.pool, id, &[]).await
}
/// List feedback for the deployment-global read-only admin plane.
@@ -470,6 +616,27 @@ impl Db {
pub async fn admin_get_feedback(&self, id: Uuid) -> Result > {
get_feedback(&self.pool, id).await
}
+
+ /// Page the admin community directory.
+ #[datastore_span(name = "admin_list_communities", system = "postgresql")]
+ pub async fn admin_list_communities(
+ &self,
+ prefix: &str,
+ after: Option<&str>,
+ limit: i64,
+ ) -> Result> {
+ list_communities(&self.pool, prefix, after, limit).await
+ }
+
+ /// Fetch one event inside one community for the admin delete preview.
+ #[datastore_span(name = "admin_get_event_preview", system = "postgresql")]
+ pub async fn admin_get_event_preview(
+ &self,
+ community_id: Uuid,
+ id: &[u8],
+ ) -> Result> {
+ get_event_preview(&self.pool, community_id, id).await
+ }
}
#[cfg(test)]
@@ -620,7 +787,7 @@ mod postgres_tests {
.await;
let report_id = insert_event_report(&pool, report_community, &event_id).await;
- let detail = get_report(&pool, report_id)
+ let detail = get_report(&pool, report_id, &admin_hidden_kinds())
.await
.expect("query report")
.expect("report exists");
@@ -707,6 +874,92 @@ mod postgres_tests {
delete_report_fixture(&pool, other_community).await;
}
+ /// A report on a hidden-kind event exposes neither the event's content nor
+ /// its author, in the list or the detail; a report on a message does.
+ #[tokio::test]
+ #[ignore = "requires Postgres"]
+ async fn report_reads_hide_private_kind_targets() {
+ let pool = setup_pool().await;
+ let community_id = insert_community(&pool, "hidden-target").await;
+ let author = [5_u8; 32];
+ let mut hidden = Vec::new();
+ // Enumerate the core sets directly so a set dropped from
+ // `admin_hidden_kinds()` still gets a fixture and fails the test.
+ let private_kinds = AUTHOR_ONLY_KINDS
+ .iter()
+ .chain(RESULT_GATED_KINDS)
+ .chain(P_GATED_KINDS)
+ .chain(SHARED_GATED_KINDS)
+ .map(|&kind| kind as i32);
+ for (i, kind) in private_kinds.enumerate() {
+ let event_id = vec![0x40 + i as u8; 32];
+ sqlx::query(
+ r#"
+ INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig)
+ VALUES ($1, $2, $3, now(), $4, '[["shared","true"]]'::jsonb, 'private', $5)
+ "#,
+ )
+ .bind(community_id)
+ .bind(&event_id)
+ .bind(author)
+ .bind(kind)
+ .bind(vec![3_u8; 64])
+ .execute(&pool)
+ .await
+ .expect("insert hidden event");
+ hidden.push((
+ kind,
+ insert_event_report(&pool, community_id, &event_id).await,
+ ));
+ }
+ let visible_id = vec![0x3f_u8; 32];
+ insert_event(&pool, community_id, &visible_id, &author, "public", None).await;
+ let visible = insert_event_report(&pool, community_id, &visible_id).await;
+
+ let reports = list_reports(
+ &pool,
+ Some(community_id),
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ 100,
+ )
+ .await
+ .expect("list reports");
+ let author_of = |id: Uuid| {
+ reports
+ .iter()
+ .find(|r| r.id == id)
+ .expect("report listed")
+ .target_author_pubkey
+ .clone()
+ };
+ for &(kind, report_id) in &hidden {
+ assert_eq!(author_of(report_id), None, "list, kind {kind}");
+ let detail = get_report(&pool, report_id, &admin_hidden_kinds())
+ .await
+ .expect("query report")
+ .expect("report exists");
+ assert!(detail.message.is_none(), "detail, kind {kind}");
+ }
+ assert_eq!(author_of(visible), Some(hex::encode(author)));
+ let detail = get_report(&pool, visible, &admin_hidden_kinds())
+ .await
+ .expect("query report")
+ .expect("report exists");
+ assert_eq!(detail.message.expect("message shown").content, "public");
+
+ sqlx::query("DELETE FROM events WHERE community_id = $1")
+ .bind(community_id)
+ .execute(&pool)
+ .await
+ .expect("delete event fixtures");
+ delete_report_fixture(&pool, community_id).await;
+ }
+
#[tokio::test]
#[ignore = "requires Postgres"]
async fn report_detail_has_no_message_for_non_event_target() {
@@ -714,7 +967,7 @@ mod postgres_tests {
let community_id = insert_community(&pool, "pubkey-target").await;
let report_id = insert_pubkey_report(&pool, community_id).await;
- let detail = get_report(&pool, report_id)
+ let detail = get_report(&pool, report_id, &admin_hidden_kinds())
.await
.expect("query report")
.expect("report exists");
@@ -732,7 +985,7 @@ mod postgres_tests {
let missing_event_id = vec![8_u8; 32];
let report_id = insert_event_report(&pool, community_id, &missing_event_id).await;
- let detail = get_report(&pool, report_id)
+ let detail = get_report(&pool, report_id, &admin_hidden_kinds())
.await
.expect("query report")
.expect("report exists");
@@ -799,7 +1052,7 @@ mod postgres_tests {
.await;
set_report_status(&pool, community_id, report_id, "dismissed").await;
- let detail = get_report(&pool, report_id)
+ let detail = get_report(&pool, report_id, &admin_hidden_kinds())
.await
.expect("query report")
.expect("report exists");
@@ -839,7 +1092,7 @@ mod postgres_tests {
.await;
set_report_status(&pool, community_id, report_id, "resolved").await;
- let detail = get_report(&pool, report_id)
+ let detail = get_report(&pool, report_id, &admin_hidden_kinds())
.await
.expect("query report")
.expect("report exists");
@@ -873,7 +1126,7 @@ mod postgres_tests {
)
.await;
- let detail = get_report(&pool, report_id)
+ let detail = get_report(&pool, report_id, &admin_hidden_kinds())
.await
.expect("query report")
.expect("report exists");
diff --git a/crates/buzz-db/src/store/user.rs b/crates/buzz-db/src/store/user.rs
index 72b74c26f9..e6f9ad4cd5 100644
--- a/crates/buzz-db/src/store/user.rs
+++ b/crates/buzz-db/src/store/user.rs
@@ -229,7 +229,7 @@ pub async fn get_user_by_nip05(
///
/// Without this, a search query of `"%"` would match every row (full table
/// scan) and `"_"` would act as a single-character wildcard.
-fn escape_like(input: &str) -> String {
+pub(crate) fn escape_like(input: &str) -> String {
input
.replace('\\', "\\\\")
.replace('%', "\\%")
diff --git a/crates/buzz-relay/src/api/admin/auth.rs b/crates/buzz-relay/src/api/admin/auth.rs
index 0c0b826255..89961eb1a9 100644
--- a/crates/buzz-relay/src/api/admin/auth.rs
+++ b/crates/buzz-relay/src/api/admin/auth.rs
@@ -1,6 +1,15 @@
//! Authentication and principal resolution for the deployment-admin API.
//!
-//! # NIP-98 mode (mutations available)
+//! Every route makes one of three checks against the [`AdminAccess`] that
+//! [`authorize`] returns:
+//!
+//! | Check | Routes | `nip98` | `disabled` |
+//! |-------|--------|---------|------------|
+//! | view | every moderation read | any staff role | served to any caller |
+//! | act | every mutation | staff, then role checks | 403 |
+//! | operator | `/operators` | Operator | 403 |
+//!
+//! # NIP-98 mode (default)
//!
//! Every request carries `Authorization: Nostr `. After
//! verifying the signature, timestamp, `u` tag, method tag, and (for
@@ -21,13 +30,15 @@
//! Config outranks DB: a `relay_operators` DB row for a config-backed
//! Operator pubkey is ignored; it never demotes a config grant.
//!
-//! # disabled mode (read-only)
+//! # disabled mode (network-trusted, read-only)
//!
-//! `authorize()` succeeds for read requests but returns `None` for the
-//! principal — mutations and staffing routes call
-//! [`require_mutation_principal`], which 403s on `None`.
+//! `authorize()` checks no credential and returns
+//! [`AdminAccess::NetworkTrusted`]: whoever can reach the relay may view every
+//! moderation read. There is no identity, so [`AdminAccess::act`] and
+//! [`AdminAccess::operator`] refuse with 403; an anonymous caller is never given
+//! a principal.
-use axum::http::{header, HeaderMap};
+use axum::http::{header, HeaderMap, Method, Uri};
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
@@ -67,8 +78,7 @@ pub enum AdminSource {
Db,
}
-/// A resolved deployment-level principal, returned by [`authorize`] in nip98
-/// mode.
+/// A resolved deployment-level principal, carried by [`AdminAccess::Staff`].
#[derive(Debug, Clone)]
pub struct AdminPrincipal {
/// 32-byte pubkey (binary).
@@ -189,18 +199,16 @@ fn method_has_body(method: &str) -> bool {
/// deserialize the same bytes. Never pass `None` for a body-bearing method in
/// nip98 mode — the `payload` sha256 tag would be skipped.
///
-/// Returns:
-/// - `Ok(Some(principal))` — nip98 mode (role resolved from roster).
-/// - `Ok(None)` — disabled mode; reads pass, mutations 403 via
-/// [`require_mutation_principal`].
-/// - `Err(_)` — authentication or authorization failed.
+/// `Ok(_)` is the view check: the caller may read. Writes and staffing then
+/// call [`AdminAccess::act`] or [`AdminAccess::operator`]. `Err(_)` means
+/// authentication or authorization failed.
pub async fn authorize(
state: &AppState,
headers: &HeaderMap,
path_and_query: &str,
method: &str,
raw_body: Option<&[u8]>,
-) -> Result, ApiError> {
+) -> Result {
let config = state
.config
.admin
@@ -209,8 +217,8 @@ pub async fn authorize(
// Credential check first: an unauthenticated caller learns nothing about
// which Host or Origin the deployment expects.
- let (principal, nip98_event_id) = match &config.auth {
- AdminAuth::Disabled => (None, None),
+ let (access, nip98_event_id) = match &config.auth {
+ AdminAuth::Disabled => (AdminAccess::NetworkTrusted, None),
AdminAuth::Nip98 => {
let full_path = format!("{ADMIN_API_PREFIX}{path_and_query}");
let (pubkey_bytes, event_id) =
@@ -220,7 +228,7 @@ pub async fn authorize(
// must not be able to consume replay slots at request rate. Only a
// request that clears authorization claims its event ID.
let principal = resolve_admin_principal(state, pubkey_bytes).await?;
- (Some(principal), Some(event_id))
+ (AdminAccess::Staff(principal), Some(event_id))
}
};
@@ -242,7 +250,46 @@ pub async fn authorize(
claim_nip98_replay(state, &event_id).await?;
}
- Ok(principal)
+ Ok(access)
+}
+
+/// [`authorize`] for a bodyless read, bound to the request's real method and
+/// full target. Axum serves `HEAD` through `GET` handlers, so hardcoding
+/// `"GET"` would reject a correctly signed `HEAD`.
+pub async fn authorize_read(
+ state: &AppState,
+ headers: &HeaderMap,
+ method: &Method,
+ uri: &Uri,
+) -> Result {
+ authorize(state, headers, request_target(uri), method.as_str(), None).await
+}
+
+/// [`authorize`] for a mutation, bound to the request's real method and full
+/// target. Hardcoding the method would reject every correctly signed request
+/// to a handler mounted on a second method. `raw_body` follows [`authorize`]'s
+/// contract: the buffered body bytes, or `None` only for a bodyless `DELETE`.
+pub async fn authorize_write(
+ state: &AppState,
+ headers: &HeaderMap,
+ method: &Method,
+ uri: &Uri,
+ raw_body: Option<&[u8]>,
+) -> Result {
+ authorize(
+ state,
+ headers,
+ request_target(uri),
+ method.as_str(),
+ raw_body,
+ )
+ .await
+}
+
+/// The full request target NIP-98 clients sign: path plus any query string.
+fn request_target(uri: &Uri) -> &str {
+ uri.path_and_query()
+ .map_or_else(|| uri.path(), |pq| pq.as_str())
}
/// Resolve a 32-byte pubkey to an `AdminPrincipal` using config + DB.
@@ -333,25 +380,38 @@ pub async fn lookup_admin_principal(
Ok(None)
}
-/// Require that this request resolved a principal (nip98 mode) and return it.
-/// Mutation and staffing routes are unavailable in disabled mode.
-///
-/// Returns the principal or a 403 if none was resolved.
-pub fn require_mutation_principal(
- principal: Option,
-) -> Result {
- principal
- .ok_or_else(|| ApiError::forbidden_with_message("mutations require BUZZ_ADMIN_AUTH=nip98"))
+/// What [`authorize`] established about the caller.
+#[derive(Debug, Clone)]
+pub enum AdminAccess {
+ /// `disabled` mode: the network vouches for the caller; there is no
+ /// identity. May view, never act or staff.
+ NetworkTrusted,
+ /// `nip98` mode: a signed, rostered staff member.
+ Staff(AdminPrincipal),
}
-/// Require that the principal holds Operator role. Used by staffing routes.
-pub fn require_operator(principal: &AdminPrincipal) -> Result<(), ApiError> {
- if principal.role == AdminRole::Operator {
- Ok(())
- } else {
- Err(ApiError::forbidden_with_message(
- "staffing endpoints require operator role",
- ))
+impl AdminAccess {
+ /// The act check: a write needs a signed staff member. Role-specific
+ /// checks run on the returned principal.
+ pub fn act(self) -> Result {
+ match self {
+ Self::Staff(principal) => Ok(principal),
+ Self::NetworkTrusted => Err(ApiError::forbidden_with_message(
+ "this endpoint requires BUZZ_ADMIN_AUTH=nip98",
+ )),
+ }
+ }
+
+ /// The operator check: roster routes need a signed Operator.
+ pub fn operator(self) -> Result {
+ let principal = self.act()?;
+ if principal.role == AdminRole::Operator {
+ Ok(principal)
+ } else {
+ Err(ApiError::forbidden_with_message(
+ "staffing endpoints require operator role",
+ ))
+ }
}
}
diff --git a/crates/buzz-relay/src/api/admin/direct.rs b/crates/buzz-relay/src/api/admin/direct.rs
index 9abafb73b0..b262c7827c 100644
--- a/crates/buzz-relay/src/api/admin/direct.rs
+++ b/crates/buzz-relay/src/api/admin/direct.rs
@@ -12,16 +12,14 @@ use std::sync::Arc;
use axum::{
body::Bytes,
extract::{Path, Query, State},
- http::{HeaderMap, StatusCode, Uri},
+ http::{HeaderMap, Method, StatusCode, Uri},
Json,
};
use buzz_db::relay_admin_actions::{AdminActionRecord, DirectActionInput, DirectClaim};
use serde::Deserialize;
use uuid::Uuid;
-use super::auth::{
- admin_role_str, authorize, lookup_admin_principal, require_mutation_principal, AdminRole,
-};
+use super::auth::{admin_role_str, authorize_write, lookup_admin_principal, AdminRole};
use super::error::ApiError;
use super::{compute_timeout_until, decode_hex_pubkey, CommunityQuery};
use crate::handlers::report_resolution::{drive_direct_action, ResolutionError};
@@ -44,35 +42,38 @@ enum Kind {
pub(super) async fn ban(
State(state): State>,
+ method: Method,
uri: Uri,
headers: HeaderMap,
Path(target): Path,
Query(q): Query,
body: Bytes,
) -> Result<(StatusCode, Json), ApiError> {
- direct_action(state, uri, headers, Kind::Ban, target, q, body).await
+ direct_action(state, method, uri, headers, Kind::Ban, target, q, body).await
}
pub(super) async fn timeout(
State(state): State>,
+ method: Method,
uri: Uri,
headers: HeaderMap,
Path(target): Path,
Query(q): Query,
body: Bytes,
) -> Result<(StatusCode, Json), ApiError> {
- direct_action(state, uri, headers, Kind::Timeout, target, q, body).await
+ direct_action(state, method, uri, headers, Kind::Timeout, target, q, body).await
}
pub(super) async fn delete_event(
State(state): State>,
+ method: Method,
uri: Uri,
headers: HeaderMap,
Path(target): Path,
Query(q): Query,
body: Bytes,
) -> Result<(StatusCode, Json), ApiError> {
- direct_action(state, uri, headers, Kind::Delete, target, q, body).await
+ direct_action(state, method, uri, headers, Kind::Delete, target, q, body).await
}
/// 409 `target_is_staff` when `target` is on the effective staff roster
@@ -101,6 +102,7 @@ pub(super) fn enforcement_failed(action_id: Uuid, error: &str) -> ApiError {
#[allow(clippy::too_many_arguments)]
async fn direct_action(
state: Arc,
+ method: Method,
uri: Uri,
headers: HeaderMap,
kind: Kind,
@@ -108,12 +110,9 @@ async fn direct_action(
q: CommunityQuery,
body_bytes: Bytes,
) -> Result<(StatusCode, Json), ApiError> {
- let path = uri
- .path_and_query()
- .map_or_else(|| uri.path(), |pq| pq.as_str());
- let principal = require_mutation_principal(
- authorize(&state, &headers, path, "POST", Some(&body_bytes)).await?,
- )?;
+ let principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes))
+ .await?
+ .act()?;
let body: DirectBody = serde_json::from_slice(&body_bytes)
.map_err(|_| ApiError::bad_request("invalid_body", "invalid JSON body"))?;
let target = decode_hex_pubkey(&target_hex)?;
diff --git a/crates/buzz-relay/src/api/admin/error.rs b/crates/buzz-relay/src/api/admin/error.rs
index ab3876c7c9..8507f86e2f 100644
--- a/crates/buzz-relay/src/api/admin/error.rs
+++ b/crates/buzz-relay/src/api/admin/error.rs
@@ -118,7 +118,8 @@ impl IntoResponse for ApiError {
}
impl From for ApiError {
- fn from(_: buzz_db::DbError) -> Self {
+ fn from(err: buzz_db::DbError) -> Self {
+ tracing::error!(error = %err, "admin API database error");
Self::internal()
}
}
diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs
index 2c3a356744..20935e9fed 100644
--- a/crates/buzz-relay/src/api/admin/mod.rs
+++ b/crates/buzz-relay/src/api/admin/mod.rs
@@ -1,23 +1,26 @@
//! Private deployment moderation API.
//!
-//! Read routes are available in both auth modes (nip98, disabled).
-//! Mutation and staffing routes require an authenticated `nip98` principal
-//! (per-person, attributed to the resolved operator).
+//! Every route is a view (moderation read), act (mutation) or operator
+//! (`/operators`) route; see [`auth`] for the rule. In `nip98` mode every route
+//! needs a signed, rostered staff member. In `disabled` mode every moderation
+//! read is served to whoever can reach the relay, and act and operator routes
+//! answer 403.
mod auth;
mod direct;
mod error;
+mod reads;
use std::sync::Arc;
use auth::{
- admin_role_str, admin_source_str, authorize, require_mutation_principal, require_operator,
- AdminRole, AdminSource,
+ admin_role_str, admin_source_str, authorize_read, authorize_write, AdminAccess, AdminRole,
+ AdminSource,
};
use axum::{
body::Bytes,
extract::{Path, Query, State},
- http::{header, HeaderMap, HeaderValue, Uri},
+ http::{header, HeaderMap, HeaderValue, Method, Uri},
middleware::{self, Next},
response::Response,
routing::{delete, get, patch, put},
@@ -40,9 +43,8 @@ pub(crate) use auth::admin_api_origin;
/// Build the deployment-admin routes.
///
-/// Read routes are available in all auth modes.
-/// Mutation routes (/reports/{id}/resolve, /feedback/{id}) and staffing routes
-/// (/operators) require an authenticated `nip98` principal.
+/// Moderation reads (GET) are served in both auth modes. Mutations and the
+/// staffing routes (/operators) require an authenticated `nip98` principal.
pub fn router(state: Arc) -> Router {
Router::new()
.route("/probe", get(probe))
@@ -61,6 +63,10 @@ pub fn router(state: Arc) -> Router {
.route("/operators", get(list_operators))
.route("/operators/{pubkey}", put(upsert_operator))
.route("/operators/{pubkey}", delete(delete_operator))
+ .route("/communities", get(reads::communities))
+ .route("/members/search", get(reads::search_members))
+ .route("/members/{pubkey}", get(reads::lookup_member))
+ .route("/events/{id}", get(reads::event_preview))
.route("/members/restrictions", get(list_member_restrictions))
.route("/members/{pubkey}/ban", delete(unban_member))
.route("/members/{pubkey}/timeout", delete(untimeout_member))
@@ -116,12 +122,14 @@ struct ReportQuery {
limit: Option,
}
-fn limit(value: Option) -> Result {
- match value.unwrap_or(50) {
- value @ 1..=200 => Ok(value),
+/// A page size: `default` when absent, otherwise 1 to `max`, else 400
+/// `invalid_limit`.
+fn limit(value: Option, default: i64, max: i64) -> Result {
+ match value.unwrap_or(default) {
+ value if (1..=max).contains(&value) => Ok(value),
_ => Err(ApiError::bad_request(
"invalid_limit",
- "limit must be between 1 and 200",
+ &format!("limit must be between 1 and {max}"),
)),
}
}
@@ -158,26 +166,13 @@ struct ProbeResponse {
async fn probe(
State(state): State>,
uri: Uri,
+ method: Method,
headers: HeaderMap,
) -> Result, ApiError> {
- let principal = authorize(
- &state,
- &headers,
- uri.path_and_query()
- .map_or_else(|| uri.path(), |pq| pq.as_str()),
- "GET",
- None,
- )
- .await?;
-
- let (auth_mode, role, source, can_act, can_staff) = match &state.config.admin {
- Some(config) => match &config.auth {
- crate::config::AdminAuth::Disabled => ("disabled", None, None, false, false),
- crate::config::AdminAuth::Nip98 => {
- // principal is Some in nip98 mode (authorize returns Ok(Some(_)))
- let p = principal
- .as_ref()
- .expect("nip98 mode always resolves principal");
+ let (auth_mode, role, source, can_act, can_staff) =
+ match authorize_read(&state, &headers, &method, &uri).await? {
+ AdminAccess::NetworkTrusted => ("disabled", None, None, false, false),
+ AdminAccess::Staff(p) => {
let can_staff = p.role == AdminRole::Operator;
(
"nip98",
@@ -187,9 +182,7 @@ async fn probe(
can_staff,
)
}
- },
- None => return Err(ApiError::not_found()),
- };
+ };
Ok(Json(ProbeResponse {
status: "ok",
@@ -204,18 +197,11 @@ async fn probe(
async fn reports(
State(state): State>,
uri: Uri,
+ method: Method,
headers: HeaderMap,
Query(query): Query,
) -> Result>, ApiError> {
- authorize(
- &state,
- &headers,
- uri.path_and_query()
- .map_or_else(|| uri.path(), |pq| pq.as_str()),
- "GET",
- None,
- )
- .await?;
+ authorize_read(&state, &headers, &method, &uri).await?;
validate(
query.status.as_deref(),
REPORT_STATUS_ALLOWLIST,
@@ -246,7 +232,7 @@ async fn reports(
query.after,
query.before,
None,
- limit(query.limit)?,
+ limit(query.limit, 50, 200)?,
)
.await?;
Ok(Json(items))
@@ -255,18 +241,11 @@ async fn reports(
async fn report_detail(
State(state): State>,
uri: Uri,
+ method: Method,
headers: HeaderMap,
Path(id): Path,
) -> Result, ApiError> {
- authorize(
- &state,
- &headers,
- uri.path_and_query()
- .map_or_else(|| uri.path(), |pq| pq.as_str()),
- "GET",
- None,
- )
- .await?;
+ authorize_read(&state, &headers, &method, &uri).await?;
state
.db
.admin_get_report(id)
@@ -294,17 +273,10 @@ struct FeedbackSummary {
async fn feedback(
State(state): State>,
uri: Uri,
+ method: Method,
headers: HeaderMap,
) -> Result>, ApiError> {
- authorize(
- &state,
- &headers,
- uri.path_and_query()
- .map_or_else(|| uri.path(), |pq| pq.as_str()),
- "GET",
- None,
- )
- .await?;
+ authorize_read(&state, &headers, &method, &uri).await?;
let items = state
.db
.admin_list_feedback(100)
@@ -330,18 +302,11 @@ async fn feedback(
async fn feedback_detail(
State(state): State>,
uri: Uri,
+ method: Method,
headers: HeaderMap,
Path(id): Path,
) -> Result, ApiError> {
- authorize(
- &state,
- &headers,
- uri.path_and_query()
- .map_or_else(|| uri.path(), |pq| pq.as_str()),
- "GET",
- None,
- )
- .await?;
+ authorize_read(&state, &headers, &method, &uri).await?;
state
.db
.admin_get_feedback(id)
@@ -353,18 +318,11 @@ async fn feedback_detail(
async fn feedback_attachment(
State(state): State>,
uri: Uri,
+ method: Method,
headers: HeaderMap,
Path((id, sha256)): Path<(Uuid, String)>,
) -> Result {
- authorize(
- &state,
- &headers,
- uri.path_and_query()
- .map_or_else(|| uri.path(), |pq| pq.as_str()),
- "GET",
- None,
- )
- .await?;
+ authorize_read(&state, &headers, &method, &uri).await?;
if !is_sha256(&sha256) {
return Err(ApiError::not_found());
}
@@ -485,6 +443,7 @@ fn compute_timeout_until(secs: u64) -> Result, ApiError> {
/// - delete/kick/ban/timeout: server-side enforcement state machine.
async fn resolve_report(
State(state): State>,
+ method: Method,
uri: Uri,
headers: HeaderMap,
Path(report_id): Path,
@@ -495,17 +454,9 @@ async fn resolve_report(
resolve_report_with_enforcement, ResolutionError,
};
- let principal_opt = authorize(
- &state,
- &headers,
- uri.path_and_query()
- .map_or_else(|| uri.path(), |pq| pq.as_str()),
- "POST",
- Some(&body_bytes),
- )
- .await?;
-
- let principal = require_mutation_principal(principal_opt)?;
+ let principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes))
+ .await?
+ .act()?;
let body: ResolveReportBody = serde_json::from_slice(&body_bytes)
.map_err(|_e| ApiError::bad_request("invalid_body", "invalid JSON body"))?;
@@ -516,10 +467,11 @@ async fn resolve_report(
return Err(ApiError::bad_request("invalid_action", "unknown action"));
}
- // Load report globally to derive target provenance.
+ // Load report globally to derive target provenance. Enforcement needs the
+ // real target author, so this read is unfiltered and never returned.
let report_detail = state
.db
- .admin_get_report(report_id)
+ .admin_get_report_for_enforcement(report_id)
.await?
.ok_or_else(ApiError::not_found)?;
@@ -713,6 +665,7 @@ struct ReopenReportBody {
/// records a durable `reopen` audit row. `409` if the report is not terminal.
async fn reopen_report(
State(state): State>,
+ method: Method,
uri: Uri,
headers: HeaderMap,
Path(report_id): Path,
@@ -720,17 +673,9 @@ async fn reopen_report(
) -> Result, ApiError> {
use buzz_db::relay_admin_actions::ReopenResult;
- let principal_opt = authorize(
- &state,
- &headers,
- uri.path_and_query()
- .map_or_else(|| uri.path(), |pq| pq.as_str()),
- "POST",
- Some(&body_bytes),
- )
- .await?;
-
- let principal = require_mutation_principal(principal_opt)?;
+ let principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes))
+ .await?
+ .act()?;
let body: ReopenReportBody = serde_json::from_slice(&body_bytes)
.map_err(|_| ApiError::bad_request("invalid_body", "invalid JSON body"))?;
@@ -799,22 +744,15 @@ struct CancelReportBody {
/// `activeAction: null`.
async fn cancel_report(
State(state): State>,
+ method: Method,
uri: Uri,
headers: HeaderMap,
Path(report_id): Path,
body_bytes: Bytes,
) -> Result, ApiError> {
- let principal_opt = authorize(
- &state,
- &headers,
- uri.path_and_query()
- .map_or_else(|| uri.path(), |pq| pq.as_str()),
- "POST",
- Some(&body_bytes),
- )
- .await?;
-
- let principal = require_mutation_principal(principal_opt)?;
+ let principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes))
+ .await?
+ .act()?;
let body: CancelReportBody = serde_json::from_slice(&body_bytes)
.map_err(|_| ApiError::bad_request("invalid_body", "invalid JSON body"))?;
@@ -875,22 +813,15 @@ async fn cancel_report(
/// Update product_feedback status. Requires nip98 auth.
async fn update_feedback_status(
State(state): State>,
+ method: Method,
uri: Uri,
headers: HeaderMap,
Path(id): Path,
body_bytes: Bytes,
) -> Result, ApiError> {
- let principal_opt = authorize(
- &state,
- &headers,
- uri.path_and_query()
- .map_or_else(|| uri.path(), |pq| pq.as_str()),
- "PATCH",
- Some(&body_bytes),
- )
- .await?;
-
- let _principal = require_mutation_principal(principal_opt)?;
+ let _principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes))
+ .await?
+ .act()?;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -938,20 +869,12 @@ struct OperatorEntry {
async fn list_operators(
State(state): State>,
uri: Uri,
+ method: Method,
headers: HeaderMap,
) -> Result>, ApiError> {
- let principal_opt = authorize(
- &state,
- &headers,
- uri.path_and_query()
- .map_or_else(|| uri.path(), |pq| pq.as_str()),
- "GET",
- None,
- )
- .await?;
-
- let principal = require_mutation_principal(principal_opt)?;
- require_operator(&principal)?;
+ authorize_read(&state, &headers, &method, &uri)
+ .await?
+ .operator()?;
let config = state
.config
@@ -1020,23 +943,15 @@ struct UpsertOperatorBody {
/// Requires nip98 auth + Operator role.
async fn upsert_operator(
State(state): State>,
+ method: Method,
uri: Uri,
headers: HeaderMap,
Path(pubkey_hex): Path,
body_bytes: Bytes,
) -> Result, ApiError> {
- let principal_opt = authorize(
- &state,
- &headers,
- uri.path_and_query()
- .map_or_else(|| uri.path(), |pq| pq.as_str()),
- "PUT",
- Some(&body_bytes),
- )
- .await?;
-
- let principal = require_mutation_principal(principal_opt)?;
- require_operator(&principal)?;
+ let principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes))
+ .await?
+ .operator()?;
// Canonicalize the path param once: validate it decodes to 32 bytes, then
// lowercase it. Config-backed pubkeys are lowercased at parse, so the 409
@@ -1099,22 +1014,14 @@ async fn upsert_operator(
/// Requires nip98 auth + Operator role.
async fn delete_operator(
State(state): State>,
+ method: Method,
uri: Uri,
headers: HeaderMap,
Path(pubkey_hex): Path,
) -> Result, ApiError> {
- let principal_opt = authorize(
- &state,
- &headers,
- uri.path_and_query()
- .map_or_else(|| uri.path(), |pq| pq.as_str()),
- "DELETE",
- None,
- )
- .await?;
-
- let principal = require_mutation_principal(principal_opt)?;
- require_operator(&principal)?;
+ let principal = authorize_write(&state, &headers, &method, &uri, None)
+ .await?
+ .operator()?;
// Canonicalize the path param once (validate + lowercase) so the 409 check
// and the DB delete use the same form config-backed pubkeys are stored in;
@@ -1244,7 +1151,7 @@ fn decode_cursor(token: &str) -> Result<(DateTime, Vec), ApiError> {
}
#[derive(Deserialize)]
-#[serde(rename_all = "camelCase")]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CommunityQuery {
community_host: String,
}
@@ -1295,20 +1202,13 @@ struct RestrictionsQuery {
async fn list_member_restrictions(
State(state): State>,
uri: Uri,
+ method: Method,
headers: HeaderMap,
Query(query): Query,
) -> Result, ApiError> {
- authorize(
- &state,
- &headers,
- uri.path_and_query()
- .map_or_else(|| uri.path(), |pq| pq.as_str()),
- "GET",
- None,
- )
- .await?;
+ authorize_read(&state, &headers, &method, &uri).await?;
- let page_limit = limit(Some(query.limit.unwrap_or(200)))?;
+ let page_limit = limit(query.limit, 200, 200)?;
let cursor = query.cursor.as_deref().map(decode_cursor).transpose()?;
let community = community_for_host(&state, &query.community_host).await?;
@@ -1338,22 +1238,15 @@ async fn list_member_restrictions(
/// Requires nip98 auth.
async fn unban_member(
State(state): State>,
+ method: Method,
uri: Uri,
headers: HeaderMap,
Path(pubkey_hex): Path,
Query(query): Query,
) -> Result {
- let principal_opt = authorize(
- &state,
- &headers,
- uri.path_and_query()
- .map_or_else(|| uri.path(), |pq| pq.as_str()),
- "DELETE",
- None,
- )
- .await?;
-
- let principal = require_mutation_principal(principal_opt)?;
+ let principal = authorize_write(&state, &headers, &method, &uri, None)
+ .await?
+ .act()?;
let target_bytes = decode_hex_pubkey(&pubkey_hex)?;
let community = community_for_host(&state, &query.community_host).await?;
@@ -1386,22 +1279,15 @@ async fn unban_member(
/// Requires nip98 auth.
async fn untimeout_member(
State(state): State>,
+ method: Method,
uri: Uri,
headers: HeaderMap,
Path(pubkey_hex): Path,
Query(query): Query,
) -> Result {
- let principal_opt = authorize(
- &state,
- &headers,
- uri.path_and_query()
- .map_or_else(|| uri.path(), |pq| pq.as_str()),
- "DELETE",
- None,
- )
- .await?;
-
- let principal = require_mutation_principal(principal_opt)?;
+ let principal = authorize_write(&state, &headers, &method, &uri, None)
+ .await?
+ .act()?;
let target_bytes = decode_hex_pubkey(&pubkey_hex)?;
let community = community_for_host(&state, &query.community_host).await?;
@@ -1592,7 +1478,7 @@ mod postgres_tests {
/// Rostered as a config operator in `test_state()` so `authorized()` can
/// mint NIP-98 credentials that resolve to an Operator principal without a
/// DB lookup.
- fn test_operator_keys() -> nostr::Keys {
+ pub(super) fn test_operator_keys() -> nostr::Keys {
nostr::Keys::parse("0000000000000000000000000000000000000000000000000000000000000001")
.expect("valid test secret key")
}
@@ -1606,7 +1492,7 @@ mod postgres_tests {
nip98_state(vec![test_operator_keys().public_key().to_hex()]).await
}
- async fn disabled_mode_state() -> Arc {
+ pub(super) async fn disabled_mode_state() -> Arc {
let mut config = crate::config::Config::for_test(); // [FI-TRACE-ENV-RACE]
config.require_relay_membership = false;
config.redis_url = "redis://127.0.0.1:1".to_string();
@@ -1662,6 +1548,10 @@ mod postgres_tests {
"/feedback".to_string(),
format!("/feedback/{id}"),
format!("/feedback/{id}/attachments/{HASH}"),
+ "/communities".to_string(),
+ "/members/search?communityHost=a.example&q=a".to_string(),
+ format!("/members/{HASH}?communityHost=a.example"),
+ format!("/events/{HASH}?communityHost=a.example"),
]
}
@@ -2236,7 +2126,9 @@ mod postgres_tests {
/// Build an AppState that uses a real Postgres connection pool so HTTP
/// routes that hit the DB can commit and read back results.
- async fn nip98_state_with_real_pool(pool: sqlx::PgPool) -> Arc {
+ pub(super) async fn nip98_state_with_real_pool(
+ pool: sqlx::PgPool,
+ ) -> Arc {
let mut config = crate::config::Config::for_test(); // [FI-TRACE-ENV-RACE]
config.require_relay_membership = false;
config.redis_url = "redis://127.0.0.1:1".to_string();
@@ -3031,7 +2923,7 @@ mod postgres_tests {
/// Build a test AppState in nip98 mode with the given operator pubkeys
/// (populated in relay_operator_pubkeys config) and an AlwaysFreshReplayGuard.
- async fn nip98_state(pubkeys: Vec) -> Arc {
+ pub(super) async fn nip98_state(pubkeys: Vec) -> Arc {
nip98_state_with_replay(pubkeys, Arc::new(AlwaysFreshReplayGuard)).await
}
@@ -3092,7 +2984,7 @@ mod postgres_tests {
/// on `admin.example` (the test host). The path should be the handler-level
/// path (e.g. `/reports`); this helper prefixes it with `ADMIN_API_PREFIX`
/// to match the canonical URL the auth layer constructs in production.
- fn make_nostr_auth(keys: &nostr::Keys, path: &str) -> String {
+ pub(super) fn make_nostr_auth(keys: &nostr::Keys, path: &str) -> String {
use nostr::{EventBuilder, Kind, Tag};
let url = format!("https://admin.example{ADMIN_API_PREFIX}{path}");
let tags = vec![
@@ -3889,7 +3781,7 @@ mod postgres_tests {
/// Build a NIP-98 `Authorization: Nostr` header from an explicit raw tag
/// list, so a test can inject duplicate `u`/`method`/`payload` tags that the
/// typed helpers can't express. Signs a real kind-27235 event.
- fn make_nostr_auth_raw_tags(keys: &nostr::Keys, tags: Vec) -> String {
+ pub(super) fn make_nostr_auth_raw_tags(keys: &nostr::Keys, tags: Vec) -> String {
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
use nostr::{EventBuilder, Kind};
@@ -4024,8 +3916,8 @@ mod postgres_tests {
}
/// POST /reports/{id}/resolve in disabled mode → 403. Disabled mode is
- /// always read-only: `authorize()` resolves no principal, so
- /// `require_mutation_principal` rejects every mutation with 403.
+ /// read-only: `authorize()` returns `NetworkTrusted`, so the act check
+ /// rejects every mutation with 403.
#[tokio::test]
async fn mutation_routes_in_disabled_mode_return_403() {
let state = disabled_mode_state().await;
@@ -4096,12 +3988,112 @@ mod postgres_tests {
);
}
- /// Moderator cannot access staffing endpoints.
+ /// Staffing is Operator-only for signed callers: a DB-rostered Moderator
+ /// gets 403 on every `/operators` route and writes neither the target's
+ /// roster row nor its audit log, while a config Operator succeeds on the
+ /// same requests. This is the only
+ /// test that tells `.operator()` apart from `.act()`; unsigned and
+ /// disabled-mode requests are refused by both.
#[tokio::test]
#[ignore = "requires Postgres — moderator DB lookup"]
async fn moderator_cannot_access_staffing_endpoints() {
- // This test needs DB to resolve moderator role.
- // Covered by negative-matrix integration test suite.
+ let operator_keys = nostr::Keys::generate();
+ let state = nip98_state(vec![operator_keys.public_key().to_hex()]).await;
+ let pool = sqlx::PgPool::connect(&database_url())
+ .await
+ .expect("connect to test DB");
+ let moderator_keys = nostr::Keys::generate();
+ let target_keys = nostr::Keys::generate();
+ let target_hex = target_keys.public_key().to_hex();
+ let target_bytes = target_keys.public_key().to_bytes().to_vec();
+ let grant_body = r#"{"role":"moderator"}"#.as_bytes();
+
+ let send = |keys: &nostr::Keys, method: &str, path: &str| {
+ let auth = match method {
+ "GET" => make_nostr_auth(keys, path),
+ "PUT" => make_nostr_auth_put(keys, path, grant_body),
+ "DELETE" => make_nostr_auth_delete(keys, path),
+ other => unreachable!("unexpected method {other}"),
+ };
+ let body = if method == "PUT" {
+ Body::from(grant_body.to_vec())
+ } else {
+ Body::empty()
+ };
+ let request = Request::builder()
+ .method(method)
+ .uri(path)
+ .header(header::HOST, "admin.example")
+ .header(header::AUTHORIZATION, auth)
+ .header(header::CONTENT_TYPE, "application/json")
+ .body(body)
+ .expect("request");
+ status_for(state.clone(), request)
+ };
+ let target_rows = || async {
+ sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM relay_operators WHERE pubkey = $1")
+ .bind(&target_bytes)
+ .fetch_one(&pool)
+ .await
+ .expect("count roster rows")
+ };
+ // The target's full roster row plus its audit-row count. The target is
+ // a fresh key, so scoping the audit count to it ignores concurrent tests.
+ let target_snapshot = || async {
+ sqlx::query_as::<_, (Option, i64)>(
+ "SELECT (SELECT r::text FROM relay_operators r WHERE r.pubkey = $1), \
+ (SELECT COUNT(*) FROM relay_operator_audit WHERE target_pubkey = $1)",
+ )
+ .bind(&target_bytes)
+ .fetch_one(&pool)
+ .await
+ .expect("snapshot target roster and audit")
+ };
+
+ // Roster the Moderator in the DB, and the target too so DELETE has a row.
+ for keys in [&moderator_keys, &target_keys] {
+ let path = format!("/operators/{}", keys.public_key().to_hex());
+ let granted = send(&operator_keys, "PUT", &path).await;
+ assert_eq!(granted.status(), StatusCode::OK, "seed grant {path}");
+ }
+
+ let target_path = format!("/operators/{target_hex}");
+ for (method, path) in [
+ ("GET", "/operators"),
+ ("PUT", target_path.as_str()),
+ ("DELETE", target_path.as_str()),
+ ] {
+ let before = target_snapshot().await;
+ let denied = send(&moderator_keys, method, path).await;
+ assert_eq!(
+ denied.status(),
+ StatusCode::FORBIDDEN,
+ "Moderator {method} {path} must be refused"
+ );
+ assert_eq!(
+ target_snapshot().await,
+ before,
+ "refused Moderator {method} {path} must not write the roster or audit log"
+ );
+ }
+
+ for (method, path) in [
+ ("GET", "/operators"),
+ ("PUT", target_path.as_str()),
+ ("DELETE", target_path.as_str()),
+ ] {
+ let allowed = send(&operator_keys, method, path).await;
+ assert_eq!(allowed.status(), StatusCode::OK, "Operator {method} {path}");
+ }
+ assert_eq!(
+ target_rows().await,
+ 0,
+ "Operator DELETE must remove the row"
+ );
+
+ let moderator_path = format!("/operators/{}", moderator_keys.public_key().to_hex());
+ let cleanup = send(&operator_keys, "DELETE", &moderator_path).await;
+ assert_eq!(cleanup.status(), StatusCode::OK, "cleanup revoke");
}
/// Config-backed pubkey upsert → 409 Conflict.
@@ -7316,6 +7308,197 @@ mod postgres_tests {
assert!(banned, "the stored author must be banned");
}
+ /// Seed an event report in the `admin.example` community whose target is a
+ /// hidden (shared-gated) kind authored by `author`. Returns the report id.
+ async fn seed_hidden_kind_event_report(pool: &sqlx::PgPool, author: &[u8]) -> Uuid {
+ let host_report = seed_admin_host_report(pool, "open").await;
+ let community_id: Uuid =
+ sqlx::query_scalar("SELECT community_id FROM moderation_reports WHERE id = $1")
+ .bind(host_report)
+ .fetch_one(pool)
+ .await
+ .expect("admin.example community");
+ cleanup_admin_host_report(pool, host_report).await;
+ let (report_id, _channel_id, target_event_id) =
+ e2e_event_report_with_author(pool, community_id, author).await;
+ sqlx::query(
+ r#"UPDATE events SET kind = $3, tags = '[["shared","true"]]'::jsonb
+ WHERE community_id = $1 AND id = $2"#,
+ )
+ .bind(community_id)
+ .bind(target_event_id.as_slice())
+ .bind(buzz_core::kind::SHARED_GATED_KINDS[0] as i32)
+ .execute(pool)
+ .await
+ .expect("make target a hidden kind");
+ report_id
+ }
+
+ /// Ban through the real resolve route on a report whose target is a hidden
+ /// kind: the real author is banned, and report detail still hides it.
+ #[tokio::test]
+ #[ignore = "requires Postgres — resolve ban on a hidden-kind target bans the author, detail stays hidden"]
+ async fn resolve_ban_on_hidden_kind_target_bans_author_without_revealing_it() {
+ let keys = nostr::Keys::generate();
+ let state = nip98_state(vec![keys.public_key().to_hex()]).await;
+ let pool = e2e_pool().await;
+ let author = nostr::Keys::generate().public_key().to_bytes().to_vec();
+ let report_id = seed_hidden_kind_event_report(&pool, &author).await;
+
+ let path = format!("/reports/{report_id}/resolve");
+ let body = serde_json::json!({ "action": "ban", "requestId": Uuid::new_v4() }).to_string();
+ let response = status_for(
+ state.clone(),
+ Request::builder()
+ .method("POST")
+ .uri(&path)
+ .header(header::HOST, "admin.example")
+ .header(
+ header::AUTHORIZATION,
+ make_nostr_auth_post(&keys, &path, body.as_bytes()),
+ )
+ .header(header::CONTENT_TYPE, "application/json")
+ .body(Body::from(body))
+ .expect("request"),
+ )
+ .await;
+ assert_eq!(response.status(), StatusCode::OK, "ban must be accepted");
+ let resolved: serde_json::Value = serde_json::from_slice(
+ &axum::body::to_bytes(response.into_body(), usize::MAX)
+ .await
+ .expect("body"),
+ )
+ .expect("json");
+ let mut keys_seen: Vec<&str> = resolved
+ .as_object()
+ .expect("object")
+ .keys()
+ .map(String::as_str)
+ .collect();
+ keys_seen.sort_unstable();
+ assert_eq!(
+ keys_seen,
+ ["activeAction", "status"],
+ "resolve returns only status/action"
+ );
+ assert!(
+ !resolved.to_string().contains(&hex::encode(&author)),
+ "resolve response must not reveal the hidden target's author"
+ );
+
+ let banned: bool = sqlx::query_scalar(
+ "SELECT EXISTS (SELECT 1 FROM community_bans b JOIN moderation_reports r \
+ ON r.community_id = b.community_id WHERE r.id = $1 AND b.pubkey = $2)",
+ )
+ .bind(report_id)
+ .bind(&author)
+ .fetch_one(&pool)
+ .await
+ .expect("ban existence");
+ assert!(banned, "the hidden target's author must be banned");
+
+ let detail_path = format!("/reports/{report_id}");
+ let response = status_for(
+ state,
+ Request::builder()
+ .uri(&detail_path)
+ .header(header::HOST, "admin.example")
+ .header(header::AUTHORIZATION, make_nostr_auth(&keys, &detail_path))
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await;
+ assert_eq!(response.status(), StatusCode::OK);
+ let detail: serde_json::Value = serde_json::from_slice(
+ &axum::body::to_bytes(response.into_body(), usize::MAX)
+ .await
+ .expect("body"),
+ )
+ .expect("json");
+ assert!(detail["message"].is_null(), "detail must hide the target");
+ assert!(detail["targetAuthorPubkey"].is_null());
+ }
+
+ /// An accepted-but-unfinished ban on a hidden-kind target converges in the
+ /// recovery worker, which re-derives the author from the report.
+ #[tokio::test]
+ #[ignore = "requires Postgres — stranded ban on a hidden-kind target recovers"]
+ async fn stranded_ban_on_hidden_kind_target_recovers_via_worker() {
+ let pool = e2e_pool().await;
+ let author = nostr::Keys::generate().public_key().to_bytes().to_vec();
+ let report_id = seed_hidden_kind_event_report(&pool, &author).await;
+ let (community_id, channel_id): (Uuid, Option) =
+ sqlx::query_as("SELECT community_id, channel_id FROM moderation_reports WHERE id = $1")
+ .bind(report_id)
+ .fetch_one(&pool)
+ .await
+ .expect("report row");
+ let actor = vec![0x61u8; 32];
+
+ let action_id = match buzz_db::relay_admin_actions::claim_report(
+ &pool,
+ buzz_core::CommunityId::from_uuid(community_id),
+ report_id,
+ Uuid::new_v4(),
+ &actor,
+ "operator",
+ "ban",
+ None,
+ None,
+ "resolve:ban",
+ "relay_operator",
+ Some(author.as_slice()),
+ None,
+ channel_id,
+ )
+ .await
+ .expect("claim")
+ {
+ buzz_db::relay_admin_actions::ClaimResult::Claimed(a) => a.id,
+ other => panic!("expected Claimed, got {other:?}"),
+ };
+ let _ = buzz_db::relay_admin_actions::begin_enforcing(&pool, action_id)
+ .await
+ .expect("begin_enforcing");
+ sqlx::query(
+ "UPDATE relay_admin_actions SET action_lease_expires_at = $2, action_lease_token = NULL WHERE id = $1",
+ )
+ .bind(action_id)
+ .bind(chrono::Utc::now() - chrono::Duration::seconds(300))
+ .execute(&pool)
+ .await
+ .expect("expire lease");
+
+ let claim = buzz_db::relay_admin_actions::claim_stranded_action_batch(
+ &pool,
+ "e2e-stranded-ban-hidden",
+ chrono::Utc::now() + chrono::Duration::seconds(120),
+ 1000,
+ )
+ .await
+ .expect("claim_stranded_action_batch")
+ .into_iter()
+ .find(|c| c.record.id == action_id)
+ .expect("stranded action must appear in batch");
+ let state = state_from_pool(pool.clone()).await;
+ crate::handlers::admin_action_worker::recover_one(&state, claim).await;
+
+ let final_rec = buzz_db::relay_admin_actions::get_action(&pool, action_id)
+ .await
+ .expect("get_action")
+ .expect("exists");
+ assert_eq!(final_rec.state, "succeeded", "stranded ban must converge");
+ let banned: bool = sqlx::query_scalar(
+ "SELECT EXISTS (SELECT 1 FROM community_bans WHERE community_id = $1 AND pubkey = $2)",
+ )
+ .bind(community_id)
+ .bind(&author)
+ .fetch_one(&pool)
+ .await
+ .expect("ban existence");
+ assert!(banned, "the hidden target's author must be banned");
+ }
+
#[tokio::test]
#[ignore = "requires Postgres — HTTP kick on a purged event report fails pre-claim without dirtying the report"]
async fn http_kick_on_event_report_with_missing_event_rejects_pre_claim() {
diff --git a/crates/buzz-relay/src/api/admin/reads.rs b/crates/buzz-relay/src/api/admin/reads.rs
new file mode 100644
index 0000000000..6cb3e58635
--- /dev/null
+++ b/crates/buzz-relay/src/api/admin/reads.rs
@@ -0,0 +1,255 @@
+//! Read-only community reads for the Admin Console: the community directory,
+//! member search and lookup, and the delete preview.
+//!
+//! Like every moderation read, these make the view check: signed staff in
+//! `nip98` mode, any caller who can reach the relay in `disabled` mode. Each
+//! community-scoped route binds `communityHost` through the tenant binder and
+//! reads nothing outside that community.
+
+use std::sync::Arc;
+
+use axum::{
+ extract::{Path, Query, State},
+ http::{HeaderMap, Method, StatusCode, Uri},
+ Json,
+};
+use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
+use buzz_db::admin_moderation::{AdminCommunity, AdminEventPreview};
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+
+use super::auth::{authorize_read, lookup_admin_principal, AdminAccess};
+use super::error::ApiError;
+use super::{community_for_host, decode_hex_pubkey, limit, CommunityQuery};
+use crate::state::AppState;
+
+type AppStateRef = State>;
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub(super) struct CommunitiesQuery {
+ q: Option,
+ cursor: Option,
+ limit: Option,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub(super) struct CommunitiesPage {
+ items: Vec,
+ next_cursor: Option,
+}
+
+/// The cursor is the last row's `lower(host)`, which is unique, so it alone
+/// resumes the directory order.
+fn encode_community_cursor(community: &AdminCommunity) -> String {
+ URL_SAFE_NO_PAD.encode(community.host.to_lowercase())
+}
+
+fn decode_community_cursor(token: &str) -> Result {
+ URL_SAFE_NO_PAD
+ .decode(token)
+ .ok()
+ .and_then(|bytes| String::from_utf8(bytes).ok())
+ .filter(|host| !host.is_empty())
+ .ok_or_else(|| ApiError::bad_request("invalid_cursor", "cursor is invalid"))
+}
+
+/// `GET /communities?q=&cursor=&limit=` — active communities whose host starts
+/// with `q` (at most 255 characters, the host length), paged by `lower(host)`.
+/// `limit` is 1–100, default 50.
+pub(super) async fn communities(
+ State(state): AppStateRef,
+ method: Method,
+ uri: Uri,
+ headers: HeaderMap,
+ Query(query): Query,
+) -> Result, ApiError> {
+ authorize_read(&state, &headers, &method, &uri).await?;
+ let limit = limit(query.limit, 50, 100)?;
+ let after = query
+ .cursor
+ .as_deref()
+ .map(decode_community_cursor)
+ .transpose()?;
+ let prefix = query.q.as_deref().unwrap_or("").trim();
+ if prefix.chars().count() > 255 {
+ return Err(ApiError::bad_request(
+ "invalid_query",
+ "q must be at most 255 characters",
+ ));
+ }
+ let items = state
+ .db
+ .admin_list_communities(prefix, after.as_deref(), limit)
+ .await?;
+ let next_cursor = (items.len() as i64 == limit)
+ .then(|| items.last().map(encode_community_cursor))
+ .flatten();
+ Ok(Json(CommunitiesPage { items, next_cursor }))
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase", deny_unknown_fields)]
+pub(super) struct MemberSearchQuery {
+ community_host: String,
+ q: String,
+ limit: Option,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct MemberSearchItem {
+ pubkey: String,
+ display_name: Option,
+ nip05: Option,
+ avatar_url: Option,
+}
+
+#[derive(Serialize)]
+pub(super) struct MemberSearchPage {
+ items: Vec,
+}
+
+/// `GET /members/search?communityHost=&q=&limit=` — profile search inside one
+/// community. Matches community profiles, so former members are included.
+/// `limit` is 1–50, default 20.
+pub(super) async fn search_members(
+ State(state): AppStateRef,
+ method: Method,
+ uri: Uri,
+ headers: HeaderMap,
+ Query(query): Query,
+) -> Result, ApiError> {
+ authorize_read(&state, &headers, &method, &uri).await?;
+ let q = query.q.trim();
+ if q.is_empty() || q.chars().count() > 100 {
+ return Err(ApiError::bad_request(
+ "invalid_query",
+ "q must be 1 to 100 characters",
+ ));
+ }
+ let limit = u32::try_from(limit(query.limit, 20, 50)?).map_err(|_| ApiError::internal())?;
+ let community = community_for_host(&state, &query.community_host).await?;
+ let items = state
+ .db
+ .search_users(community, q, limit)
+ .await?
+ .into_iter()
+ .map(|user| MemberSearchItem {
+ pubkey: hex::encode(user.pubkey),
+ display_name: user.display_name,
+ nip05: user.nip05_handle,
+ avatar_url: user.avatar_url,
+ })
+ .collect();
+ Ok(Json(MemberSearchPage { items }))
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct MemberProfile {
+ display_name: Option,
+ nip05: Option,
+ avatar_url: Option,
+ about: Option,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub(super) struct MemberLookup {
+ pubkey: String,
+ profile: Option,
+ role: Option,
+ banned: bool,
+ muted_until: Option>,
+ /// Whether the pubkey is deployment staff; `null` in `disabled` mode,
+ /// where no caller can act on the answer and the roster is not read.
+ is_staff: Option,
+}
+
+/// Whether `pubkey` is deployment staff, using the direct-action staff guard's
+/// roster lookup. A lookup failure is an error (500), never "not staff".
+async fn is_staff(state: &AppState, pubkey: &[u8]) -> Result {
+ let pubkey: [u8; 32] = pubkey.try_into().map_err(|_| ApiError::internal())?;
+ Ok(lookup_admin_principal(state, pubkey).await?.is_some())
+}
+
+/// `GET /members/{pubkey}?communityHost=` — profile, community role, current
+/// restriction and, for a signed caller, staff status for one pubkey in one
+/// community.
+pub(super) async fn lookup_member(
+ State(state): AppStateRef,
+ method: Method,
+ uri: Uri,
+ headers: HeaderMap,
+ Path(pubkey_hex): Path,
+ Query(query): Query,
+) -> Result, ApiError> {
+ let access = authorize_read(&state, &headers, &method, &uri).await?;
+ let pubkey = decode_hex_pubkey(&pubkey_hex)?;
+ let pubkey_hex = hex::encode(&pubkey);
+ let community = community_for_host(&state, &query.community_host).await?;
+ let profile = state.db.get_user(community, &pubkey).await?;
+ let role = state.db.get_relay_member(community, &pubkey_hex).await?;
+ let restriction = state
+ .db
+ .moderation_restriction_state(community, &pubkey)
+ .await?;
+ let is_staff = match access {
+ AdminAccess::Staff(_) => Some(is_staff(&state, &pubkey).await?),
+ AdminAccess::NetworkTrusted => None,
+ };
+ Ok(Json(MemberLookup {
+ profile: profile.map(|p| MemberProfile {
+ display_name: p.display_name,
+ nip05: p.nip05_handle,
+ avatar_url: p.avatar_url,
+ about: p.about,
+ }),
+ role: role.map(|member| member.role),
+ banned: restriction.banned,
+ muted_until: restriction.muted_until,
+ is_staff,
+ pubkey: pubkey_hex,
+ }))
+}
+
+/// The one answer for an event the preview will not show: a malformed id, an
+/// id stored only in another community, or an author-only or result-gated
+/// event are all indistinguishable from an absent one.
+fn event_not_found() -> ApiError {
+ ApiError {
+ status: StatusCode::NOT_FOUND,
+ code: "event_not_found",
+ message: "event was not found in this community".to_owned(),
+ }
+}
+
+/// `GET /events/{id}?communityHost=` — one event inside one community, for the
+/// delete preview. Anything else is [`event_not_found`].
+pub(super) async fn event_preview(
+ State(state): AppStateRef,
+ method: Method,
+ uri: Uri,
+ headers: HeaderMap,
+ Path(id_hex): Path,
+ Query(query): Query,
+) -> Result, ApiError> {
+ authorize_read(&state, &headers, &method, &uri).await?;
+ let id = hex::decode(&id_hex)
+ .ok()
+ .filter(|id| id.len() == 32)
+ .ok_or_else(event_not_found)?;
+ let community = community_for_host(&state, &query.community_host).await?;
+ state
+ .db
+ .admin_get_event_preview(*community.as_uuid(), &id)
+ .await?
+ .map(Json)
+ .ok_or_else(event_not_found)
+}
+
+#[cfg(test)]
+#[path = "reads_tests.rs"]
+mod postgres_tests;
diff --git a/crates/buzz-relay/src/api/admin/reads_tests.rs b/crates/buzz-relay/src/api/admin/reads_tests.rs
new file mode 100644
index 0000000000..ef64dda1be
--- /dev/null
+++ b/crates/buzz-relay/src/api/admin/reads_tests.rs
@@ -0,0 +1,968 @@
+//! Tests for the community reads, the HEAD binding of every admin read, and
+//! the view/act/operator classification of every admin route. The unsigned, HEAD
+//! and validation checks reject or answer before any database access; the rest
+//! are `#[ignore]`d and run in the PostgreSQL lane.
+
+use std::sync::Arc;
+
+use axum::{
+ body::Body,
+ http::{header, Request, StatusCode},
+};
+use buzz_core::CommunityId;
+use serde_json::Value;
+use tower::ServiceExt;
+use uuid::Uuid;
+
+use super::super::auth::ADMIN_API_PREFIX;
+use super::super::postgres_tests::{
+ disabled_mode_state, make_nostr_auth, make_nostr_auth_raw_tags, nip98_state,
+ nip98_state_with_real_pool, test_operator_keys,
+};
+use super::super::router;
+use crate::state::AppState;
+use crate::test_support::database_url;
+
+const PK: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+
+fn community_read_routes(host: &str) -> Vec {
+ vec![
+ "/communities".to_string(),
+ format!("/members/search?communityHost={host}&q=a"),
+ format!("/members/{PK}?communityHost={host}"),
+ format!("/events/{PK}?communityHost={host}"),
+ ]
+}
+
+async fn send(state: &Arc, request: Request) -> (StatusCode, Value) {
+ let response = router(state.clone())
+ .oneshot(request)
+ .await
+ .expect("response");
+ let status = response.status();
+ let bytes = axum::body::to_bytes(response.into_body(), 1 << 20)
+ .await
+ .expect("body");
+ (
+ status,
+ serde_json::from_slice(&bytes).unwrap_or(Value::Null),
+ )
+}
+
+fn signed_get(keys: &nostr::Keys, uri: &str) -> Request {
+ Request::builder()
+ .uri(uri)
+ .header(header::HOST, "admin.example")
+ .header(header::AUTHORIZATION, make_nostr_auth(keys, uri))
+ .body(Body::empty())
+ .expect("request")
+}
+
+/// A request to `sent` whose credential was signed for `signed_method` and
+/// `signed_uri`, so tests can tamper with the method or the query.
+fn signed_as(
+ keys: &nostr::Keys,
+ method: &str,
+ signed_method: &str,
+ signed_uri: &str,
+ sent: &str,
+) -> Request {
+ let url = format!("https://admin.example{ADMIN_API_PREFIX}{signed_uri}");
+ let tags = vec![
+ nostr::Tag::parse(["u", &url]).unwrap(),
+ nostr::Tag::parse(["method", signed_method]).unwrap(),
+ ];
+ Request::builder()
+ .method(method)
+ .uri(sent)
+ .header(header::HOST, "admin.example")
+ .header(header::AUTHORIZATION, make_nostr_auth_raw_tags(keys, tags))
+ .body(Body::empty())
+ .unwrap()
+}
+
+async fn get(state: &Arc, keys: &nostr::Keys, uri: &str) -> (StatusCode, Value) {
+ send(state, signed_get(keys, uri)).await
+}
+
+#[tokio::test]
+async fn community_reads_reject_unsigned_requests() {
+ let state = nip98_state(vec![test_operator_keys().public_key().to_hex()]).await;
+ for uri in community_read_routes("a.example") {
+ let request = Request::builder()
+ .uri(&uri)
+ .header(header::HOST, "admin.example")
+ .body(Body::empty())
+ .unwrap();
+ assert_eq!(
+ send(&state, request).await.0,
+ StatusCode::UNAUTHORIZED,
+ "{uri}"
+ );
+ }
+}
+
+/// The report, feedback, staffing and restriction GET routes, the status a
+/// HEAD-signed HEAD gets past auth, and whether producing that status needs
+/// the database.
+fn moderation_reads() -> Vec<(String, StatusCode, bool)> {
+ let id = Uuid::new_v4();
+ vec![
+ ("/probe".to_owned(), StatusCode::OK, false),
+ (
+ "/reports?status=bogus".to_owned(),
+ StatusCode::BAD_REQUEST,
+ false,
+ ),
+ (format!("/reports/{id}"), StatusCode::NOT_FOUND, true),
+ ("/feedback".to_owned(), StatusCode::OK, true),
+ (format!("/feedback/{id}"), StatusCode::NOT_FOUND, true),
+ (
+ format!("/feedback/{id}/attachments/not-a-hash"),
+ StatusCode::NOT_FOUND,
+ false,
+ ),
+ ("/operators".to_owned(), StatusCode::OK, true),
+ (
+ "/members/restrictions?communityHost=a.example&limit=0".to_owned(),
+ StatusCode::BAD_REQUEST,
+ false,
+ ),
+ ]
+}
+
+/// Axum serves HEAD through GET handlers; the credential must be checked
+/// against the real method, so a GET-signed HEAD is refused on every
+/// one of these reads, and a HEAD-signed HEAD passes auth wherever the answer needs no
+/// database (the rest are covered by the Postgres lane below).
+#[tokio::test]
+async fn moderation_reads_authorize_head_with_the_real_method() {
+ let keys = test_operator_keys();
+ let state = nip98_state(vec![keys.public_key().to_hex()]).await;
+ for (uri, want, needs_db) in moderation_reads() {
+ let head = |signed: &str| signed_as(&keys, "HEAD", signed, &uri, &uri);
+ assert_eq!(
+ send(&state, head("GET")).await.0,
+ StatusCode::UNAUTHORIZED,
+ "GET-signed HEAD {uri}"
+ );
+ if !needs_db {
+ assert_eq!(send(&state, head("HEAD")).await.0, want, "HEAD {uri}");
+ }
+ }
+}
+
+/// The credential covers the full target: a GET-signed HEAD, or a query
+/// changed after signing, is refused on every community read.
+#[tokio::test]
+async fn community_reads_reject_method_and_query_tampering() {
+ let keys = test_operator_keys();
+ let state = nip98_state(vec![keys.public_key().to_hex()]).await;
+ let mut routes = community_read_routes("a.example");
+ routes[0] = "/communities?q=a.example".to_owned();
+ for uri in routes {
+ let head = signed_as(&keys, "HEAD", "GET", &uri, &uri);
+ assert_eq!(
+ send(&state, head).await.0,
+ StatusCode::UNAUTHORIZED,
+ "{uri}"
+ );
+ let tampered = uri.replace("a.example", "b.example");
+ let request = signed_as(&keys, "GET", "GET", &uri, &tampered);
+ assert_eq!(
+ send(&state, request).await.0,
+ StatusCode::UNAUTHORIZED,
+ "{tampered}"
+ );
+ }
+}
+
+/// Out-of-range limits are refused before any database access.
+#[tokio::test]
+async fn community_reads_reject_out_of_range_limits() {
+ let keys = test_operator_keys();
+ let state = nip98_state(vec![keys.public_key().to_hex()]).await;
+ for uri in [
+ "/communities?limit=0",
+ "/communities?limit=101",
+ "/members/search?communityHost=a.example&q=a&limit=0",
+ "/members/search?communityHost=a.example&q=a&limit=51",
+ ] {
+ let (status, body) = get(&state, &keys, uri).await;
+ assert_eq!(
+ (status, body["error"]["code"].as_str()),
+ (StatusCode::BAD_REQUEST, Some("invalid_limit")),
+ "{uri}"
+ );
+ }
+}
+
+#[tokio::test]
+async fn member_search_rejects_empty_and_overlong_queries() {
+ let keys = test_operator_keys();
+ let state = nip98_state(vec![keys.public_key().to_hex()]).await;
+ for q in ["%20%20".to_owned(), "a".repeat(101)] {
+ let uri = format!("/members/search?communityHost=a.example&q={q}");
+ let (status, body) = get(&state, &keys, &uri).await;
+ assert_eq!(
+ (status, body["error"]["code"].as_str()),
+ (StatusCode::BAD_REQUEST, Some("invalid_query")),
+ "{uri}"
+ );
+ }
+}
+
+/// `q` longer than a host can be is refused before any database access.
+#[tokio::test]
+async fn community_directory_rejects_a_query_longer_than_a_host() {
+ let keys = test_operator_keys();
+ let state = nip98_state(vec![keys.public_key().to_hex()]).await;
+ let uri = format!("/communities?q={}", "a".repeat(256));
+ let (status, body) = get(&state, &keys, &uri).await;
+ assert_eq!(
+ (status, body["error"]["code"].as_str()),
+ (StatusCode::BAD_REQUEST, Some("invalid_query"))
+ );
+}
+
+/// A malformed event id gets the same answer as an absent event.
+#[tokio::test]
+async fn event_preview_answers_a_malformed_id_as_not_found() {
+ let keys = test_operator_keys();
+ let state = nip98_state(vec![keys.public_key().to_hex()]).await;
+ for id in ["zz".repeat(32), "ab".repeat(31), "ab".repeat(33)] {
+ let uri = format!("/events/{id}?communityHost=a.example");
+ let (status, body) = get(&state, &keys, &uri).await;
+ assert_eq!(
+ (status, body["error"]["code"].as_str()),
+ (StatusCode::NOT_FOUND, Some("event_not_found")),
+ "{uri}"
+ );
+ }
+}
+
+async fn community(pool: &sqlx::PgPool, host: &str) -> CommunityId {
+ buzz_db::Db::from_pool(pool.clone())
+ .ensure_configured_community(host)
+ .await
+ .expect("create community")
+ .id
+}
+
+async fn seed_profile(pool: &sqlx::PgPool, c: CommunityId, pubkey: &[u8], name: &str) {
+ sqlx::query(
+ "INSERT INTO users (community_id, pubkey, display_name, about) VALUES ($1, $2, $3, $3)",
+ )
+ .bind(c.as_uuid())
+ .bind(pubkey)
+ .bind(name)
+ .execute(pool)
+ .await
+ .expect("seed profile");
+}
+
+/// A raw kind-1 event row: the same id may be stored in two communities with
+/// different content, which is exactly what isolation must not leak.
+async fn seed_event(pool: &sqlx::PgPool, c: CommunityId, id: &[u8], content: &str, deleted: bool) {
+ sqlx::query(
+ "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, deleted_at) \
+ VALUES ($1, $2, $2, now(), 1, '[]', $3, $2, CASE WHEN $4 THEN now() END)",
+ )
+ .bind(c.as_uuid())
+ .bind(id)
+ .bind(content)
+ .bind(deleted)
+ .execute(pool)
+ .await
+ .expect("seed event");
+}
+
+async fn fixture() -> (sqlx::PgPool, Arc) {
+ let pool = sqlx::PgPool::connect(&database_url())
+ .await
+ .expect("connect");
+ let state = nip98_state_with_real_pool(pool.clone()).await;
+ (pool, state)
+}
+
+fn unique_host(label: &str) -> String {
+ format!("{label}-{}.example", Uuid::new_v4().simple())
+}
+
+#[tokio::test]
+#[ignore = "requires Postgres"]
+async fn community_reads_serve_operators_and_moderators_and_refuse_non_staff() {
+ let (pool, state) = fixture().await;
+ let host = unique_host("reads-auth");
+ community(&pool, &host).await;
+ let moderator = nostr::Keys::generate();
+ buzz_db::Db::from_pool(pool.clone())
+ .upsert_relay_operator(
+ &moderator.public_key().to_bytes(),
+ "moderator",
+ &[1u8; 32],
+ true,
+ )
+ .await
+ .expect("seed moderator");
+ let expected = [
+ StatusCode::OK,
+ StatusCode::OK,
+ StatusCode::OK,
+ StatusCode::NOT_FOUND,
+ ];
+ for (uri, want) in community_read_routes(&host).iter().zip(expected) {
+ for keys in [test_operator_keys(), moderator.clone()] {
+ assert_eq!(get(&state, &keys, uri).await.0, want, "{uri}");
+ let head = signed_as(&keys, "HEAD", "HEAD", uri, uri);
+ assert_eq!(send(&state, head).await.0, want, "HEAD {uri}");
+ }
+ let stranger = nostr::Keys::generate();
+ assert_eq!(
+ get(&state, &stranger, uri).await.0,
+ StatusCode::FORBIDDEN,
+ "{uri}"
+ );
+ }
+}
+
+#[tokio::test]
+#[ignore = "requires Postgres"]
+async fn community_reads_never_return_another_communitys_data() {
+ let (pool, state) = fixture().await;
+ let keys = test_operator_keys();
+ let (host_a, host_b) = (unique_host("iso-a"), unique_host("iso-b"));
+ let (a, b) = (
+ community(&pool, &host_a).await,
+ community(&pool, &host_b).await,
+ );
+
+ let shared = [0x31u8; 32];
+ let foreign = [0x32u8; 32];
+ let tag = Uuid::new_v4().simple().to_string();
+ seed_profile(&pool, a, &shared, &format!("{tag} in a")).await;
+ seed_profile(&pool, b, &shared, &format!("{tag} in b")).await;
+ seed_profile(&pool, b, &foreign, &format!("{tag} only b")).await;
+ sqlx::query("INSERT INTO relay_members (community_id, pubkey, role) VALUES ($1, $2, 'admin')")
+ .bind(b.as_uuid())
+ .bind(hex::encode(shared))
+ .execute(&pool)
+ .await
+ .unwrap();
+ sqlx::query("INSERT INTO community_bans (community_id, pubkey, banned, actor_pubkey) VALUES ($1, $2, true, $2)")
+ .bind(b.as_uuid())
+ .bind(shared.as_slice())
+ .execute(&pool)
+ .await
+ .unwrap();
+ let (shared_event, foreign_event) = ([0x41u8; 32], [0x42u8; 32]);
+ seed_event(&pool, a, &shared_event, "content in a", false).await;
+ seed_event(&pool, b, &shared_event, "content in b", false).await;
+ seed_event(&pool, b, &foreign_event, "only in b", false).await;
+
+ let (status, found) = get(
+ &state,
+ &keys,
+ &format!("/members/search?communityHost={host_a}&q={tag}"),
+ )
+ .await;
+ assert_eq!(status, StatusCode::OK, "{found}");
+ let names: Vec<&str> = found["items"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|i| i["displayName"].as_str().unwrap())
+ .collect();
+ assert_eq!(names, [format!("{tag} in a")]);
+
+ let (_, member) = get(
+ &state,
+ &keys,
+ &format!("/members/{}?communityHost={host_a}", hex::encode(shared)),
+ )
+ .await;
+ assert_eq!(member["profile"]["displayName"], format!("{tag} in a"));
+ assert_eq!(
+ (member["role"].clone(), member["banned"].clone()),
+ (Value::Null, false.into())
+ );
+ let (_, member) = get(
+ &state,
+ &keys,
+ &format!("/members/{}?communityHost={host_b}", hex::encode(shared)),
+ )
+ .await;
+ assert_eq!(
+ (member["role"].clone(), member["banned"].clone()),
+ ("admin".into(), true.into()),
+ "{member}"
+ );
+
+ let (status, stranger) = get(
+ &state,
+ &keys,
+ &format!("/members/{}?communityHost={host_a}", hex::encode(foreign)),
+ )
+ .await;
+ assert_eq!(status, StatusCode::OK);
+ assert_eq!(
+ (stranger["profile"].clone(), stranger["role"].clone()),
+ (Value::Null, Value::Null)
+ );
+
+ let (_, event) = get(
+ &state,
+ &keys,
+ &format!(
+ "/events/{}?communityHost={host_a}",
+ hex::encode(shared_event)
+ ),
+ )
+ .await;
+ assert_eq!(event["content"], "content in a");
+ let (status, missing) = get(
+ &state,
+ &keys,
+ &format!(
+ "/events/{}?communityHost={host_a}",
+ hex::encode(foreign_event)
+ ),
+ )
+ .await;
+ assert_eq!(
+ (status, missing["error"]["code"].as_str()),
+ (StatusCode::NOT_FOUND, Some("event_not_found"))
+ );
+}
+
+#[tokio::test]
+#[ignore = "requires Postgres"]
+async fn community_directory_pages_live_hosts_by_literal_prefix() {
+ let (pool, state) = fixture().await;
+ let keys = test_operator_keys();
+ let p = format!("dir{}", Uuid::new_v4().simple());
+ let live = [
+ format!("{p}-a.example"),
+ format!("{p}-b.example"),
+ format!("{p}-c.example"),
+ format!("{p}%lit.example"),
+ format!("{p}_u.example"),
+ ];
+ for host in &live {
+ sqlx::query("INSERT INTO communities (host) VALUES ($1)")
+ .bind(host)
+ .execute(&pool)
+ .await
+ .unwrap();
+ }
+ for (host, archived, state_, deleted) in [
+ (format!("{p}-arch.example"), true, "active", false),
+ (format!("{p}-del.example"), false, "quiescing", false),
+ (format!("{p}-gone.example"), false, "tombstone", true),
+ ] {
+ sqlx::query(
+ "INSERT INTO communities (host, archived_at, deletion_state, deleted_at) \
+ VALUES ($1, CASE WHEN $2 THEN now() END, $3, CASE WHEN $4 THEN now() END)",
+ )
+ .bind(host)
+ .bind(archived)
+ .bind(state_)
+ .bind(deleted)
+ .execute(&pool)
+ .await
+ .unwrap();
+ }
+
+ let hosts = |page: &Value| -> Vec {
+ page["items"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|i| i["host"].as_str().unwrap().to_owned())
+ .collect()
+ };
+ // Paging with limit 2 visits every live host once, then stops.
+ // The page cap turns a cursor that never ends into a failure, not a hang.
+ let mut seen = Vec::new();
+ let mut next = Some(format!("/communities?q={}&limit=2", p.to_uppercase()));
+ for _ in 0..=live.len() {
+ let Some(uri) = next.take() else { break };
+ let (status, page) = get(&state, &keys, &uri).await;
+ assert_eq!(status, StatusCode::OK, "{page}");
+ seen.extend(hosts(&page));
+ next = page["nextCursor"]
+ .as_str()
+ .map(|cursor| format!("/communities?q={p}&limit=2&cursor={cursor}"));
+ }
+ assert!(next.is_none(), "paging did not end: {next:?}");
+ let mut want = live.to_vec();
+ want.sort();
+ seen.sort();
+ assert_eq!(seen, want);
+
+ // `%` and `_` match themselves, not any character.
+ let (_, pct) = get(&state, &keys, &format!("/communities?q={p}%25")).await;
+ assert_eq!(hosts(&pct), [format!("{p}%lit.example")]);
+ let (_, under) = get(&state, &keys, &format!("/communities?q={p}_")).await;
+ assert_eq!(hosts(&under), [format!("{p}_u.example")]);
+
+ let (status, longest) = get(
+ &state,
+ &keys,
+ &format!("/communities?q={}", "a".repeat(255)),
+ )
+ .await;
+ assert_eq!(status, StatusCode::OK, "{longest}");
+
+ // Not base64, base64 of nothing, and base64 of invalid UTF-8.
+ for cursor in ["not-a-cursor!", "", "_w"] {
+ let (status, bad) = get(&state, &keys, &format!("/communities?cursor={cursor}")).await;
+ assert_eq!(
+ (status, bad["error"]["code"].as_str()),
+ (StatusCode::BAD_REQUEST, Some("invalid_cursor")),
+ "cursor={cursor:?}"
+ );
+ }
+}
+
+#[tokio::test]
+#[ignore = "requires Postgres"]
+async fn member_lookup_reports_staff_and_active_restrictions() {
+ let (pool, state) = fixture().await;
+ let keys = test_operator_keys();
+ let host = unique_host("lookup");
+ let c = community(&pool, &host).await;
+ let target = [0x51u8; 32];
+ sqlx::query(
+ "INSERT INTO community_bans (community_id, pubkey, banned, muted_until, actor_pubkey) \
+ VALUES ($1, $2, true, now() + interval '1 hour', $2)",
+ )
+ .bind(c.as_uuid())
+ .bind(target.as_slice())
+ .execute(&pool)
+ .await
+ .unwrap();
+ let (_, member) = get(
+ &state,
+ &keys,
+ &format!("/members/{}?communityHost={host}", hex::encode(target)),
+ )
+ .await;
+ assert_eq!(
+ (member["banned"].clone(), member["isStaff"].clone()),
+ (true.into(), false.into())
+ );
+ assert!(member["mutedUntil"].is_string(), "{member}");
+
+ let staff = keys.public_key().to_hex();
+ let (_, member) = get(
+ &state,
+ &keys,
+ &format!("/members/{staff}?communityHost={host}"),
+ )
+ .await;
+ assert_eq!(member["isStaff"], true);
+ assert_eq!(member["pubkey"], staff);
+}
+
+/// A Moderator gets the staff answer too (the desktop preflights direct
+/// actions with it); disabled mode has no signed caller and gets `null`.
+#[tokio::test]
+#[ignore = "requires Postgres"]
+async fn member_lookup_reports_staff_only_to_a_signed_caller() {
+ let (pool, state) = fixture().await;
+ let host = unique_host("lookup-staff");
+ community(&pool, &host).await;
+ let moderator = nostr::Keys::generate();
+ buzz_db::Db::from_pool(pool.clone())
+ .upsert_relay_operator(
+ &moderator.public_key().to_bytes(),
+ "moderator",
+ &[1u8; 32],
+ true,
+ )
+ .await
+ .expect("seed moderator");
+ let operator = test_operator_keys().public_key().to_hex();
+ let lookup = |pubkey: &str| format!("/members/{pubkey}?communityHost={host}");
+
+ for (pubkey, want) in [(operator.as_str(), true), (PK, false)] {
+ let (status, member) = get(&state, &moderator, &lookup(pubkey)).await;
+ assert_eq!(status, StatusCode::OK, "{member}");
+ assert_eq!(member["isStaff"], want, "{pubkey}");
+ }
+
+ let open = disabled_mode_state().await;
+ let request = Request::builder()
+ .uri(lookup(&operator))
+ .header(header::HOST, "admin.example")
+ .body(Body::empty())
+ .unwrap();
+ let (status, member) = send(&open, request).await;
+ assert_eq!(status, StatusCode::OK, "{member}");
+ assert!(member["isStaff"].is_null(), "{member}");
+}
+
+/// Only the staff-roster read fails: each connection shadows
+/// `relay_operators` with an incompatible temp table, while the community,
+/// profile, role and restriction reads still succeed. The route must answer
+/// 500, never `isStaff: false`.
+#[tokio::test]
+#[ignore = "requires Postgres"]
+async fn member_lookup_fails_closed_when_the_staff_lookup_fails() {
+ let (pool, _) = fixture().await;
+ let host = unique_host("staff-fail");
+ community(&pool, &host).await;
+ let broken = sqlx::postgres::PgPoolOptions::new()
+ .after_connect(|conn, _| {
+ Box::pin(async move {
+ sqlx::query("CREATE TEMP TABLE relay_operators (unrelated int)")
+ .execute(conn)
+ .await
+ .map(|_| ())
+ })
+ })
+ .connect(&database_url())
+ .await
+ .expect("connect");
+ let state = nip98_state_with_real_pool(broken).await;
+ let (status, body) = get(
+ &state,
+ &test_operator_keys(),
+ &format!("/members/{PK}?communityHost={host}"),
+ )
+ .await;
+ assert_eq!(
+ (status, body["error"]["code"].as_str()),
+ (StatusCode::INTERNAL_SERVER_ERROR, Some("internal_error")),
+ "{body}"
+ );
+}
+
+/// The boundary limits return exactly that many matches.
+#[tokio::test]
+#[ignore = "requires Postgres"]
+async fn member_search_returns_up_to_the_limit() {
+ let (pool, state) = fixture().await;
+ let keys = test_operator_keys();
+ let host = unique_host("search-limit");
+ let c = community(&pool, &host).await;
+ let tag = Uuid::new_v4().simple().to_string();
+ for i in 0..51u8 {
+ seed_profile(&pool, c, &[i; 32], &format!("{tag} {i}")).await;
+ }
+ for limit in [1usize, 50] {
+ let uri = format!("/members/search?communityHost={host}&q={tag}&limit={limit}");
+ let (status, page) = get(&state, &keys, &uri).await;
+ assert_eq!(status, StatusCode::OK, "{page}");
+ assert_eq!(
+ page["items"].as_array().unwrap().len(),
+ limit,
+ "limit={limit}"
+ );
+ }
+}
+
+/// The database-backed half of
+/// `moderation_reads_authorize_head_with_the_real_method`.
+#[tokio::test]
+#[ignore = "requires Postgres"]
+async fn moderation_reads_serve_a_head_signed_head() {
+ let (_, state) = fixture().await;
+ let keys = test_operator_keys();
+ for (uri, want, _) in moderation_reads().into_iter().filter(|r| r.2) {
+ let head = signed_as(&keys, "HEAD", "HEAD", &uri, &uri);
+ assert_eq!(send(&state, head).await.0, want, "HEAD {uri}");
+ }
+}
+
+#[tokio::test]
+#[ignore = "requires Postgres"]
+async fn event_preview_reports_the_stored_event_and_its_deletion() {
+ let (pool, state) = fixture().await;
+ let keys = test_operator_keys();
+ let host = unique_host("preview");
+ let c = community(&pool, &host).await;
+ let id = [0x61u8; 32];
+ seed_event(&pool, c, &id, "deleted body", true).await;
+ let (status, event) = get(
+ &state,
+ &keys,
+ &format!("/events/{}?communityHost={host}", hex::encode(id)),
+ )
+ .await;
+ assert_eq!(status, StatusCode::OK, "{event}");
+ assert_eq!(
+ (
+ event["id"].as_str(),
+ event["authorPubkey"].as_str(),
+ event["kind"].as_i64()
+ ),
+ (
+ Some(hex::encode(id).as_str()),
+ Some(hex::encode(id).as_str()),
+ Some(1)
+ )
+ );
+ assert!(
+ event["deletedAt"].is_string() && event["channelId"].is_null(),
+ "{event}"
+ );
+}
+
+/// Every author-only, result-gated, `#p`-gated and shared-gated event is
+/// answered as absent, even to staff and even when shared; a message is not.
+#[tokio::test]
+#[ignore = "requires Postgres"]
+async fn event_preview_hides_every_private_kind() {
+ use buzz_core::kind::{
+ AUTHOR_ONLY_KINDS, P_GATED_KINDS, RESULT_GATED_KINDS, SHARED_GATED_KINDS,
+ };
+ let (pool, state) = fixture().await;
+ let keys = test_operator_keys();
+ let host = unique_host("preview-hidden");
+ let c = community(&pool, &host).await;
+ for (i, &kind) in AUTHOR_ONLY_KINDS
+ .iter()
+ .chain(RESULT_GATED_KINDS)
+ .chain(P_GATED_KINDS)
+ .chain(SHARED_GATED_KINDS)
+ .enumerate()
+ {
+ let id = [0x80 + i as u8; 32];
+ sqlx::query(
+ "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig) \
+ VALUES ($1, $2, $2, now(), $3, '[[\"shared\",\"true\"]]', 'hidden', $2)",
+ )
+ .bind(c.as_uuid())
+ .bind(id)
+ .bind(kind as i32)
+ .execute(&pool)
+ .await
+ .expect("seed hidden event");
+ let uri = format!("/events/{}?communityHost={host}", hex::encode(id));
+ let (status, body) = get(&state, &keys, &uri).await;
+ assert_eq!(
+ (status, body["error"]["code"].as_str()),
+ (StatusCode::NOT_FOUND, Some("event_not_found")),
+ "kind {kind}: {body}"
+ );
+ }
+
+ let id = [0x7f_u8; 32];
+ seed_event(&pool, c, &id, "visible", false).await;
+ let uri = format!("/events/{}?communityHost={host}", hex::encode(id));
+ let (status, body) = get(&state, &keys, &uri).await;
+ assert_eq!(status, StatusCode::OK, "{body}");
+ assert_eq!(body["content"], "visible", "{body}");
+}
+
+/// Every query struct refuses an unknown field, on reads and on a write; the
+/// refused write leaves the ban in place.
+#[tokio::test]
+#[ignore = "requires Postgres"]
+async fn admin_queries_reject_unknown_fields() {
+ let (pool, state) = fixture().await;
+ let keys = test_operator_keys();
+ let host = unique_host("unknown-field");
+ let c = community(&pool, &host).await;
+ let target = [0x91u8; 32];
+ let db = buzz_db::Db::from_pool(pool.clone());
+ db.ban_community_member(c, &target, &keys.public_key().to_bytes(), None, None)
+ .await
+ .expect("seed ban");
+
+ for uri in [
+ "/communities?bogus=1".to_owned(),
+ format!("/members/search?communityHost={host}&q=a&bogus=1"),
+ format!("/members/{PK}?communityHost={host}&bogus=1"),
+ ] {
+ assert_eq!(
+ get(&state, &keys, &uri).await.0,
+ StatusCode::BAD_REQUEST,
+ "{uri}"
+ );
+ }
+
+ let uri = format!(
+ "/members/{}/ban?communityHost={host}&bogus=1",
+ hex::encode(target)
+ );
+ let unban = signed_as(&keys, "DELETE", "DELETE", &uri, &uri);
+ assert_eq!(
+ send(&state, unban).await.0,
+ StatusCode::BAD_REQUEST,
+ "{uri}"
+ );
+ let restriction = db
+ .moderation_restriction_state(c, &target)
+ .await
+ .expect("read ban");
+ assert!(
+ restriction.banned,
+ "the refused unban must not lift the ban"
+ );
+}
+
+/// Disabled mode serves the community reads to an unsigned caller, and a
+/// query for one community still returns only that community's rows.
+#[tokio::test]
+#[ignore = "requires Postgres"]
+async fn community_reads_serve_disabled_mode_within_the_selected_community() {
+ let (pool, _) = fixture().await;
+ let state = disabled_mode_state().await;
+ let (host_a, host_b) = (unique_host("open-a"), unique_host("open-b"));
+ let (a, b) = (
+ community(&pool, &host_a).await,
+ community(&pool, &host_b).await,
+ );
+ let member = [0x71u8; 32];
+ let event = [0x72u8; 32];
+ let tag = Uuid::new_v4().simple().to_string();
+ seed_profile(&pool, a, &member, &format!("{tag} in a")).await;
+ seed_profile(&pool, b, &member, &format!("{tag} in b")).await;
+ seed_event(&pool, a, &event, "content in a", false).await;
+ seed_event(&pool, b, &event, "content in b", false).await;
+
+ let state = &state;
+ let unsigned = |uri: String| async move {
+ let request = Request::builder()
+ .uri(&uri)
+ .header(header::HOST, "admin.example")
+ .body(Body::empty())
+ .unwrap();
+ let (status, body) = send(state, request).await;
+ assert_eq!(status, StatusCode::OK, "{uri}: {body}");
+ body
+ };
+
+ let directory = unsigned(format!("/communities?q={host_b}")).await;
+ assert_eq!(directory["items"][0]["host"], host_b.as_str());
+ assert_eq!(directory["items"].as_array().unwrap().len(), 1);
+
+ let found = unsigned(format!("/members/search?communityHost={host_b}&q={tag}")).await;
+ let names: Vec<&str> = found["items"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|i| i["displayName"].as_str().unwrap())
+ .collect();
+ assert_eq!(names, [format!("{tag} in b")]);
+
+ let profile = unsigned(format!(
+ "/members/{}?communityHost={host_b}",
+ hex::encode(member)
+ ))
+ .await;
+ assert_eq!(profile["profile"]["displayName"], format!("{tag} in b"));
+
+ let preview = unsigned(format!(
+ "/events/{}?communityHost={host_b}",
+ hex::encode(event)
+ ))
+ .await;
+ assert_eq!(preview["content"], "content in b");
+}
+
+/// Which check a route makes.
+#[derive(Clone, Copy, Debug, PartialEq)]
+enum Check {
+ /// Any access: signed staff in nip98, any caller in disabled mode.
+ View,
+ /// A signed staff member; refused in disabled mode.
+ Act,
+ /// A signed Operator; refused in disabled mode.
+ Operator,
+}
+
+/// Every route the admin API mounts, with its check. A new route must be
+/// added here with a deliberate policy: `every_route_is_listed` fails while
+/// the number of `.route(` registrations in `router()` differs from this list.
+fn every_route() -> Vec<(&'static str, String, Check)> {
+ use Check::{Act, Operator, View};
+ let id = Uuid::nil();
+ let host = "communityHost=a.example";
+ vec![
+ ("GET", "/probe".into(), View),
+ ("GET", "/reports".into(), View),
+ ("GET", format!("/reports/{id}"), View),
+ ("POST", format!("/reports/{id}/resolve"), Act),
+ ("POST", format!("/reports/{id}/reopen"), Act),
+ ("POST", format!("/reports/{id}/cancel"), Act),
+ ("GET", "/feedback".into(), View),
+ ("GET", format!("/feedback/{id}"), View),
+ ("PATCH", format!("/feedback/{id}"), Act),
+ ("GET", format!("/feedback/{id}/attachments/{PK}"), View),
+ ("GET", "/operators".into(), Operator),
+ ("PUT", format!("/operators/{PK}"), Operator),
+ ("DELETE", format!("/operators/{PK}"), Operator),
+ ("GET", "/communities".into(), View),
+ ("GET", format!("/members/search?{host}&q=a"), View),
+ ("GET", format!("/members/{PK}?{host}"), View),
+ ("GET", format!("/events/{PK}?{host}"), View),
+ ("GET", format!("/members/restrictions?{host}"), View),
+ ("DELETE", format!("/members/{PK}/ban?{host}"), Act),
+ ("DELETE", format!("/members/{PK}/timeout?{host}"), Act),
+ ("POST", format!("/members/{PK}/ban?{host}"), Act),
+ ("POST", format!("/members/{PK}/timeout?{host}"), Act),
+ ("POST", format!("/events/{PK}/delete?{host}"), Act),
+ ]
+}
+
+/// Counts registrations, not paths: it catches an added or removed `.route(`
+/// call, not a changed path within one.
+#[test]
+fn every_route_is_listed() {
+ let source = include_str!("mod.rs");
+ let start = source.find("pub fn router(").expect("router() in mod.rs");
+ let body = &source[start..];
+ let body = &body[..body.find("\n}\n").expect("end of router()")];
+ assert_eq!(body.matches(".route(").count(), every_route().len());
+}
+
+fn unsigned_request(method: &str, uri: &str) -> Request {
+ Request::builder()
+ .method(method)
+ .uri(uri)
+ .header(header::HOST, "admin.example")
+ .header(header::CONTENT_TYPE, "application/json")
+ .body(Body::from("{}"))
+ .unwrap()
+}
+
+/// nip98 demands a credential on every route; disabled mode serves every view
+/// route and refuses every act and operator route.
+///
+/// The route list is written by hand: a new route must be added here on
+/// purpose. View rows only prove no credential is asked for (they accept 404
+/// or 500); `community_reads_serve_disabled_mode_within_the_selected_community`
+/// is what proves the reads return data. Unsigned and disabled-mode requests
+/// cannot tell `.operator()` from `.act()`; `moderator_cannot_access_staffing_endpoints`
+/// covers that boundary.
+#[tokio::test]
+#[ignore = "requires Postgres"]
+async fn every_route_makes_its_check_in_both_auth_modes() {
+ let signed = nip98_state(vec![test_operator_keys().public_key().to_hex()]).await;
+ let open = disabled_mode_state().await;
+ for (method, uri, check) in every_route() {
+ let (status, _) = send(&signed, unsigned_request(method, &uri)).await;
+ assert_eq!(status, StatusCode::UNAUTHORIZED, "nip98 {method} {uri}");
+
+ let (status, body) = send(&open, unsigned_request(method, &uri)).await;
+ if check == Check::View {
+ assert!(
+ status != StatusCode::UNAUTHORIZED && status != StatusCode::FORBIDDEN,
+ "disabled {method} {uri} ({check:?}) answered {status}: {body}"
+ );
+ } else {
+ assert_eq!(
+ (status, body["error"]["message"].as_str()),
+ (
+ StatusCode::FORBIDDEN,
+ Some("this endpoint requires BUZZ_ADMIN_AUTH=nip98")
+ ),
+ "disabled {method} {uri} ({check:?})"
+ );
+ }
+ }
+}
diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs
index 65f3199280..589dc25719 100644
--- a/crates/buzz-relay/src/config.rs
+++ b/crates/buzz-relay/src/config.rs
@@ -39,16 +39,17 @@ pub enum ConfigError {
/// - `Moderator/Db` from the `relay_operators` table otherwise
/// - `None` → 403 (no fall-through role, ever)
///
-/// Disabled mode is always read-only. NIP-98 mode is read-write per resolved
-/// principal.
+/// Disabled mode serves every moderation read and refuses writes and staffing.
+/// NIP-98 mode is read-write per resolved principal.
#[derive(Debug, Clone)]
pub enum AdminAuth {
/// Authentication disabled. The operator has explicitly asserted
/// that the admin API is protected at the network layer (reverse proxy,
/// VPN, firewall). `Host`/`Origin` checks remain active as defense-in-depth.
/// Selected by `BUZZ_ADMIN_AUTH=disabled`.
- /// Always read-only: `authorize()` resolves no principal for this mode, so
- /// mutation and staffing routes always 403.
+ /// Every moderation read is served to anyone who can reach the relay;
+ /// `authorize()` returns `NetworkTrusted` with no identity, so write and
+ /// staffing routes always 403.
Disabled,
/// NIP-98 HTTP Auth. Every request must carry an `Authorization: Nostr`
/// header containing a signed kind-27235 event. The authenticated pubkey
@@ -60,8 +61,7 @@ pub enum AdminAuth {
}
/// Deny-by-default deployment-admin configuration. Mutation and staffing routes
-/// require a resolved principal (NIP-98 only); disabled mode is always
-/// read-only.
+/// require a resolved principal (NIP-98 only); disabled mode serves reads only.
#[derive(Debug, Clone)]
pub struct AdminConfig {
/// Exact admin HTTP authority.
@@ -1287,9 +1287,13 @@ impl Config {
None | Some("") | Some("nip98") => AdminAuth::Nip98,
Some("disabled") => {
tracing::warn!(
- "BUZZ_ADMIN_AUTH=disabled — the admin API is \
- unauthenticated; the operator has asserted that access is \
- controlled at the network layer (reverse proxy, VPN, firewall)"
+ "BUZZ_ADMIN_AUTH=disabled — the admin API serves every \
+ moderation read without authentication to anyone who can \
+ reach the relay: reports, feedback and attachments, \
+ restrictions, the community directory, member profiles and \
+ any stored message by ID, in every community. Writes and \
+ staffing are refused. Keep the whole relay port private to \
+ people allowed to see all of that"
);
AdminAuth::Disabled
}
diff --git a/crates/buzz-relay/src/handlers/admin_action_worker.rs b/crates/buzz-relay/src/handlers/admin_action_worker.rs
index ec1835ff9f..c792288048 100644
--- a/crates/buzz-relay/src/handlers/admin_action_worker.rs
+++ b/crates/buzz-relay/src/handlers/admin_action_worker.rs
@@ -148,7 +148,7 @@ pub(crate) async fn recover_one(state: &Arc, claim: StrandedActionClai
// Non-kick actions: always re-derive from the report.
// Pre-migration kick rows: re-derive so the convergence gate can use the
// result as a legacy-context fallback (see report_resolution.rs).
- let report = match state.db.admin_get_report(report_id).await {
+ let report = match state.db.admin_get_report_for_enforcement(report_id).await {
Ok(Some(r)) => r,
Ok(None) => {
warn!(action_id = %action_id, "Action recovery: report not found");
diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example
index 100ec893c3..624b5c818e 100644
--- a/deploy/compose/.env.example
+++ b/deploy/compose/.env.example
@@ -46,8 +46,13 @@ BUZZ_S3_ADDRESSING_STYLE=path
# RELAY_OWNER_PUBKEY (implicit Operator fallback when operator list is empty),
# and the relay_operators table (DB-managed Operator/Moderator roster).
# Dashboard requires a NIP-07 browser extension (nos2x or Alby).
-# BUZZ_ADMIN_AUTH=disabled — no auth. Use only behind a VPN or private ingress.
-# Relay logs a WARN on boot.
+# BUZZ_ADMIN_AUTH=disabled — read-only, no auth. Anyone who can reach the relay
+# can read every community's reports, feedback and attachments, bans and
+# timeouts, community directory, member profiles and any stored message by
+# ID; writes and staffing are refused. Only people allowed to see all of that
+# may reach it. The admin API is on the relay's main port and its hostname
+# check uses the caller's Host header, so the published relay port itself must
+# be private; a private admin hostname is not enough. Relay logs a WARN on boot.
# Token authentication was removed: BUZZ_ADMIN_TOKEN is ignored with a startup
# warning — remove it from the environment.
# Any unrecognised BUZZ_ADMIN_AUTH value aborts startup.
diff --git a/docs/admin/README.md b/docs/admin/README.md
index fbf592ced6..c15ba13b7c 100644
--- a/docs/admin/README.md
+++ b/docs/admin/README.md
@@ -121,6 +121,7 @@ can be a break-glass root, silently discarding it would be a lockout.
| Capability | Operator | Moderator |
|---|---|---|
| Read reports, feedback, attachment bytes | ✓ | ✓ |
+| Read community directory, member profiles, and events | ✓ | ✓ |
| Resolve reports (dismiss, escalate, delete, kick, ban, timeout) | ✓ | ✓ |
| Update feedback status | ✓ | ✓ |
| Manage operator roster (`GET/PUT/DELETE /operators`) | ✓ | ✗ |
@@ -153,9 +154,8 @@ emptied freely because config still guarantees an operator.
### Disabled mode (`BUZZ_ADMIN_AUTH=disabled`)
-Operators whose admin API is already protected at the network layer — for
-example by a corporate VPN such as WARP+Okta — can disable request
-authentication entirely:
+Disabled mode turns the admin API into a read-only window with no request
+authentication:
```text
BUZZ_ADMIN_AUTH=disabled
@@ -163,23 +163,50 @@ BUZZ_ADMIN_AUTH=disabled
Only the exact value `disabled` is accepted.
-In this mode the relay logs a `WARN` on every startup:
+| | `nip98` (default) | `disabled` |
+|---|---|---|
+| Moderation reads | signed, any staff role | served to anyone who can reach the relay |
+| Writes (resolve, ban, timeout, delete, lift, feedback status) | signed, role-checked | refused (`403`) |
+| Staffing (`/operators`) | signed Operator | refused (`403`) |
+
+**What disabled mode exposes**, in every community the relay hosts, to anyone
+who can send it an HTTP request:
+
+- reports, including reporter keys, private reporter notes, resolutions and the
+ full content of reported messages, deleted ones included
+- product feedback and its attachment bytes
+- bans and timeouts, with their private reasons and the acting moderator
+- the directory of every community
+- member search and member profiles, including role, ban and timeout state
+ (staff status is not revealed in disabled mode)
+- any stored message by event ID, regardless of channel membership and
+ including deleted messages (encrypted content stays ciphertext)
+
+**Who should be able to reach it:** only people allowed to see all moderation
+data in every community. A company-wide VPN is usually a much larger group.
+
+**The whole relay port must be private, not just the admin hostname.** The admin
+API is served on the relay's main listener, and the admin hostname check
+compares against the caller's own `Host` header, which any client can set. A
+relay whose port is reachable from the internet exposes everything above even if
+the admin hostname resolves only inside a VPN. If the relay must be public, use
+`nip98`.
+
+Disabled mode records no reader identity. To act on anything, switch to `nip98`.
+
+The relay logs a `WARN` on every startup in this mode:
```
-BUZZ_ADMIN_AUTH=disabled — the admin API is unauthenticated; the operator has
-asserted that access is controlled at the network layer
+BUZZ_ADMIN_AUTH=disabled — the admin API serves every moderation read without
+authentication to anyone who can reach the relay: reports, feedback and
+attachments, restrictions, the community directory, member profiles and any
+stored message by ID, in every community. Writes and staffing are refused. Keep
+the whole relay port private to people allowed to see all of that
```
-The `Host`/`Origin` checks remain active as defense-in-depth. The dashboard
-detects that no credential is needed on first load (probe returns `200`) and
-renders directly.
-
-**This mode relies entirely on the operator's network controls.** If the admin
-API is reachable by untrusted clients, the entire moderation and feedback dataset
-is exposed. Use nip98 mode instead.
-
-When using a reverse proxy in this mode, document the requirement and consider a
-proxy-injected shared secret or signed identity header for additional assurance.
+The `Host`/`Origin` checks remain active, but they are not authentication. The
+dashboard detects that no credential is needed on first load (probe returns
+`200`) and renders read-only.
### Mode selection and error behaviour
@@ -281,8 +308,12 @@ rolling.
For local review, run `just admin-seed` before `just admin`. `just admin`
defaults to `BUZZ_ADMIN_AUTH=disabled`, so the dashboard renders without a
-credential. The seed command also uploads real image and diagnostic fixtures to
-local MinIO. Feedback search and filters run over the bounded browser result
+credential. Unless the mode is exactly `nip98`, it binds the relay to
+`127.0.0.1` on the port from `BUZZ_BIND_ADDR` (default 3000), so
+unauthenticated moderation reads stay local even when the value is padded or
+misspelled; `BUZZ_ADMIN_AUTH=nip98` keeps `BUZZ_BIND_ADDR` as configured. The
+seed command also uploads real image and diagnostic fixtures to local MinIO.
+Feedback search and filters run over the bounded browser result
set. The feedback **status** control (`new`/`reviewed`/`archived`) is
server-backed: in `nip98` mode it `PATCH`es the relay and adopts the returned
status, so every operator sees the same state; in `disabled` mode it renders as
@@ -299,6 +330,50 @@ a read-only badge because the server rejects mutations.
- `GET /api/admin/v1/feedback/:id`
- `GET /api/admin/v1/feedback/:id/attachments/:sha256`
+### Community read routes (Operator and Moderator in `nip98`; unauthenticated in `disabled` mode)
+
+The three member and event routes name the community by `communityHost` (the
+relay's own host authority, host plus port when non-default), resolving it
+through the same tenant binder that scopes live connections. A host that the
+relay serves no live community for returns `400 unknown_community_host`. All
+four reject unknown query parameters with `400` (axum's plain-text rejection,
+not the JSON error envelope, before authentication). All four accept a signed
+`HEAD` request (sign with method `HEAD`).
+
+- `GET /api/admin/v1/communities?q=&cursor=&limit=`
+ Lists live communities (not archived, not deleted). Response:
+ `{"items": [{"id", "host", "icon"|null}], "nextCursor": ""|null}`.
+ `q` is a case-insensitive literal host prefix (trimmed; `%` and `_` are
+ literal characters, not wildcards), max 255 chars else `400
+ invalid_query`. Results are ordered by lowercased host. `limit` 1–100,
+ default 50, else `400 invalid_limit`. `cursor` is opaque: pass the
+ previous page's `nextCursor`; a malformed cursor returns `400
+ invalid_cursor`. `nextCursor` is non-null when the page is full; a final
+ full page may be followed by an empty page.
+- `GET /api/admin/v1/members/search?communityHost=&q=&limit=`
+ Searches the community's stored profiles; former members appear. Response:
+ `{"items": [{"pubkey", "displayName"|null, "nip05"|null,
+ "avatarUrl"|null}]}`. `q` is trimmed, 1–100 characters else `400
+ invalid_query`. `limit` 1–50, default 20.
+- `GET /api/admin/v1/members/{pubkey}?communityHost=`
+ Returns a single member record. Response: `{"pubkey", "profile":
+ {"displayName", "nip05", "avatarUrl", "about"}|null, "role":
+ "owner"|"admin"|"member"|null, "banned": bool, "mutedUntil":
+ |null, "isStaff": bool|null}`. `role` null means the pubkey
+ is not on the community roster. `isStaff` is whether the pubkey is
+ deployment staff (Operator or Moderator); it is `null` in `disabled` mode
+ (the roster is never revealed without a signed staff caller). A roster
+ lookup failure returns `500`. For an agent pubkey, `banned` and
+ `mutedUntil` reflect the effective restriction, which includes its
+ owner's.
+- `GET /api/admin/v1/events/{id}?communityHost=`
+ Reads a single event scoped to that community, including soft-deleted
+ ones. Response: `{"id", "authorPubkey", "kind", "content", "createdAt",
+ "deletedAt"|null, "channelId"|null}`. Never returns author-only kinds
+ (event reminders, push leases, private managed agents) or result-gated
+ kinds (DM visibility, agent turn metrics). Absent, other-community,
+ excluded-kind, and malformed IDs all return `404 event_not_found`.
+
### Action routes (Operator and Moderator)
- `POST /api/admin/v1/reports/:id/resolve`
@@ -340,6 +415,8 @@ authority (host, plus port when non-default), which the relay resolves to its
tenant through the same binder that scopes live connections. The caller's
local community ids are never accepted. A host the relay serves no community
for returns `400` with error `unknown_community_host`, never an empty result.
+Unknown query parameters are rejected with `400` (axum's plain-text
+rejection, before authentication).
- `GET /api/admin/v1/members/restrictions?communityHost=[&limit=<1-200>][&cursor=]`
Lists active bans and timeouts, newest first. Response: