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: