feat(relay): add community reads to the admin API (#8011)

The in-app Admin Console needs to work on any community the relay
serves, not only the one the desktop is connected to. This adds four
read-only routes under `/api/admin/v1` that the stacked desktop PR
builds on, puts every admin route under one explicit access rule, and
binds every admin request's signature to its real method, which fixes
signed `HEAD` on reads.

## Access rule

| Mode | Moderation reads | Writes | Staffing (`/operators`) |
|---|---|---|---|
| `nip98` (default) | signed, any staff role | signed, role-checked |
signed Operator |
| `disabled` | served to anyone who can reach the relay | `403` | `403`
|

"Moderation reads" means every read: reports, feedback and attachments,
restrictions, and the four community routes below. `nip98` behavior is
unchanged, and the Operator API (`/operator/*`) is untouched.

**Existing `BUZZ_ADMIN_AUTH=disabled` deployments start serving the four
new reads** (community directory, member search, member lookup without
staff status, and stored events by ID) with no config change. The
startup `WARN`, `docs/admin/README.md`, `.env.example`,
`deploy/compose/.env.example` and the `just admin` comment list exactly
what disabled mode exposes, say it should be reachable only by people
allowed to see all moderation data in every community, and say the whole
relay port must be private: the admin API is on the relay's main
listener and its hostname check uses the caller's `Host` header. `just
admin` binds the relay to `127.0.0.1` unless `BUZZ_ADMIN_AUTH` is
exactly `nip98`, so an unset, padded or misspelled value stays local.

In code, `authorize()` returns `AdminAccess`, either `NetworkTrusted`
(disabled mode, no identity) or `Staff(AdminPrincipal)` (`nip98`, any
rostered Operator or Moderator). Each route makes one check: **view**
(`authorize()` succeeding), **act** (`AdminAccess::act()`, must be
`Staff`, then the existing role checks) or **operator**
(`AdminAccess::operator()`, must be an Operator). Act and operator
refuse `NetworkTrusted` with `403 this endpoint requires
BUZZ_ADMIN_AUTH=nip98`; no principal is ever created for an anonymous
caller. `every_route_makes_its_check_in_both_auth_modes` lists every
mounted admin route with its check and asserts the result in both modes,
and `every_route_is_listed` fails when `router()` mounts a route that
list does not name.

## Routes

All four live in `api/admin/reads.rs`; `mod.rs` only registers them.
Their queries reject unknown fields, like `ReportQuery`. The three
`?communityHost=` routes share `CommunityQuery`, which now rejects
unknown fields too, so the existing unban, untimeout and direct-action
routes that use it do the same; their desktop callers send only
`communityHost`. Every admin route that takes `limit` validates it with
the module's shared `limit()` helper, so an out-of-range value is `400
invalid_limit`.

| Route | Returns |
|---|---|
| `GET /communities?q=&cursor=&limit=` | `{items: [{id, host,
icon\|null}], nextCursor\|null}` |
| `GET /members/search?communityHost=&q=&limit=` | `{items: [{pubkey,
displayName\|null, nip05\|null, avatarUrl\|null}]}` |
| `GET /members/{pubkey}?communityHost=` | `{pubkey, profile:
{displayName, nip05, avatarUrl, about}\|null, role:
"owner"\|"admin"\|"member"\|null, banned, mutedUntil\|null, isStaff:
bool\|null}` |
| `GET /events/{id}?communityHost=` | `{id, authorPubkey, kind, content,
createdAt, deletedAt\|null, channelId\|null}` |

- **One community per read.** Community-scoped routes bind
`communityHost` through the tenant binder (`400 unknown_community_host`)
and read only that community's `users`, `relay_members`,
`community_bans` and `events` rows.
- **Directory.** It uses the binder's liveness rule (not archived, not
deleted, `deletion_state = 'active'`), so every listed host can be
opened. `q` is an escaped, case-insensitive host prefix of at most 255
characters (`400 invalid_query`). Results are ordered and paged by
`lower(host)`, which is unique, so the opaque base64url cursor carries
only the last host; a malformed cursor is `400 invalid_cursor`. `limit`
is 1 to 100 with a default of 50.
- **Member search.** It reuses `search_users`, which already escapes,
binds and scopes, with no second escaping layer. `q` is trimmed and must
be 1 to 100 characters (`400 invalid_query`), and `limit` is 1 to 50
(default 20). It searches community profiles, so former members appear.
- **Member lookup.** `isStaff` uses the same roster lookup as the
direct-action staff guard and is returned to signed staff only: it is
`null` in disabled mode, where the roster is never read. A roster lookup
failure is a 500, never "not staff".
- **Event preview.** It reads the event by `(community_id, id)` and
never returns a private kind (see below). A malformed id, an id stored
only in another community and a private kind are all `404
event_not_found`, the same answer as an absent event.

## Private events

Admin reads never reveal events the relay keeps private.
`admin_hidden_kinds()` combines the four private kind sets in
`buzz_core::kind`: `AUTHOR_ONLY_KINDS`, `RESULT_GATED_KINDS`,
`P_GATED_KINDS` and `SHARED_GATED_KINDS`. The whole shared-gated set is
hidden, including events tagged `["shared","true"]`; the shared tag is
never evaluated. Every HTTP response applies it the way it treats a
missing event: event preview is `404 event_not_found`, report detail
returns the report with `message: null` and `targetAuthorPubkey: null`,
and the report list returns a null target author.

Moderators can still act on these events, they just cannot read them.
Report detail is one query with the hidden-kind list as a parameter,
exposed two ways: `admin_get_report` hides private kinds and backs every
HTTP response (GET detail, the resolve response, reopen, cancel and the
reporter-notice worker), while `admin_get_report_for_enforcement` reads
the target unfiltered and is used only by the resolve handler's
enforcement load (the staff-target check and ban, timeout and kick
target derivation) and by the recovery worker for interrupted actions.
Its result is never serialized, so a ban from a report on a private
persona bans the real author while GET still shows nothing.

## Method binding

Axum serves `HEAD` through `GET` handlers, but the read handlers
authorized with a literal `"GET"`, so a correctly signed `HEAD` was
rejected. Admin reads now authorize through `authorize_read` and writes
through `authorize_write`, both of which use the request's real method
and full target, so no handler hardcodes a method string. A database
error behind any admin route is now logged before it becomes a 500.

## Directory query plan

`idx_communities_host` is on `lower(host)` in the default collation, so
the prefix `LIKE` is a filter, not an index condition. The plan walks
the index in order and stops once it has `limit` matches. A common
prefix is cheap (on local Postgres 16 with 21,359 communities, a first
page skipped 2,728 index rows in 3.9 ms), but a prefix with few or no
matches walks every index entry, so its cost grows with the number of
communities. A `COLLATE "C"` index would turn the prefix into an index
range; it is left out because the admin directory is low-traffic and the
cost is small at current scale.

The cursor condition is `lower(host) > COALESCE($2, '')`, with no `OR`,
so it remains an index condition under a generic plan:

```text
->  Bitmap Index Scan on idx_communities_host
      Index Cond: (lower((host)::text) > COALESCE($2, ''::text))
```

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-10-07 16:07:38 -04:00
committed by GitHub
parent f260ad7f95
commit 032198ce43
15 changed files with 2126 additions and 304 deletions

View File

@@ -119,9 +119,16 @@ BUZZ_PUSH_GATEWAY_DELIVERY_URL=https://push.buzz.xyz/v1/deliveries/apns
# the console without manual URL entry. # the console without manual URL entry.
# RELAY_OPERATOR_PUBKEYS=<64-char hex pubkey>[,<64-char hex pubkey>...] # RELAY_OPERATOR_PUBKEYS=<64-char hex pubkey>[,<64-char hex pubkey>...]
# #
# Option B — BUZZ_ADMIN_AUTH=disabled (network-layer auth only): # Option B — BUZZ_ADMIN_AUTH=disabled (read-only, no authentication):
# Set only when the admin API is already protected at the network layer # Anyone who can reach the relay can read, in every community: reports (with
# (VPN, private ingress). The relay logs a WARN on every startup. # 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. # `just admin` defaults to this mode for local review.
# #
# Directory holding the built dashboard assets (`pnpm -C admin-web build`). # Directory holding the built dashboard assets (`pnpm -C admin-web build`).

View File

@@ -675,13 +675,24 @@ admin: bootstrap _ensure-migrations
pnpm -C admin-web build pnpm -C admin-web build
export BUZZ_ADMIN_HOST="${BUZZ_ADMIN_HOST:-admin.localhost:3000}" export BUZZ_ADMIN_HOST="${BUZZ_ADMIN_HOST:-admin.localhost:3000}"
export BUZZ_ADMIN_WEB_DIR="${BUZZ_ADMIN_WEB_DIR:-{{justfile_directory()}}/admin-web/dist}" 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 # Default to disabled auth locally: a NIP-07 signer extension can't be
# NIP-07 signer extension can't be assumed in dev. Override per run with # assumed in dev. Disabled mode serves every moderation read (reports,
# BUZZ_ADMIN_AUTH=nip98 (plus RELAY_OPERATOR_PUBKEYS or RELAY_OWNER_PUBKEY) # feedback, restrictions, community directory, member profiles, any stored
# to exercise the authenticated path. # 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}" 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 "Admin dashboard: http://${BUZZ_ADMIN_HOST}/reports"
echo "Auth mode: ${BUZZ_ADMIN_AUTH} (set BUZZ_ADMIN_AUTH=nip98 to require a signed operator)" 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 cargo run -p buzz-relay
# Seed deterministic reports and product feedback for local admin dashboard review # Seed deterministic reports and product feedback for local admin dashboard review

View File

@@ -199,8 +199,7 @@ function ReportDetail({ id, authMode }: { id: string; authMode: AuthMode }) {
</div> </div>
) : ( ) : (
<p className="message-unavailable"> <p className="message-unavailable">
Message content is unavailable. It may have expired or Message content is unavailable.
been removed from event storage.
</p> </p>
)} )}
</dd> </dd>

View File

@@ -4,6 +4,7 @@
//! [`CommunityId`](buzz_core::CommunityId). Keep ordinary moderation reads in //! [`CommunityId`](buzz_core::CommunityId). Keep ordinary moderation reads in
//! [`crate::moderation`] tenant-fenced. //! [`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 buzz_datastore_tracing::datastore_span;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::Serialize; use serde::Serialize;
@@ -16,6 +17,20 @@ use crate::Db;
/// Maximum rows accepted by one admin query. /// Maximum rows accepted by one admin query.
pub const MAX_PAGE_SIZE: i64 = 200; 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<i32> {
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 { fn bounded_limit(limit: i64) -> i64 {
limit.clamp(1, MAX_PAGE_SIZE) limit.clamp(1, MAX_PAGE_SIZE)
} }
@@ -209,6 +224,7 @@ pub async fn list_reports(
WHERE r.target_kind = 'event' WHERE r.target_kind = 'event'
AND e.community_id = r.community_id AND e.community_id = r.community_id
AND e.id = r.target_event_id AND e.id = r.target_event_id
AND e.kind <> ALL($10)
ORDER BY e.created_at DESC ORDER BY e.created_at DESC
LIMIT 1 LIMIT 1
) target ON TRUE ) target ON TRUE
@@ -232,13 +248,19 @@ pub async fn list_reports(
.bind(cursor_time) .bind(cursor_time)
.bind(cursor_id) .bind(cursor_id)
.bind(bounded_limit(limit)) .bind(bounded_limit(limit))
.bind(admin_hidden_kinds())
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
rows.into_iter().map(row_to_report).collect() rows.into_iter().map(row_to_report).collect()
} }
/// Fetch one report globally by its row id, including its event target content. /// Fetch one report globally by its row id, including its event target content.
pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result<Option<AdminReportDetail>> { /// 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<Option<AdminReportDetail>> {
let row = sqlx::query( let row = sqlx::query(
r#" r#"
SELECT r.id, r.community_id, c.host AS community_host, 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<Option<AdminRe
WHERE r.target_kind = 'event' WHERE r.target_kind = 'event'
AND e.community_id = r.community_id AND e.community_id = r.community_id
AND e.id = r.target_event_id AND e.id = r.target_event_id
AND e.kind <> ALL($2)
ORDER BY e.created_at DESC ORDER BY e.created_at DESC
LIMIT 1 LIMIT 1
) target ON TRUE ) target ON TRUE
@@ -290,6 +313,7 @@ pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result<Option<AdminRe
"#, "#,
) )
.bind(report_id) .bind(report_id)
.bind(hidden_kinds)
.fetch_optional(pool) .fetch_optional(pool)
.await?; .await?;
row.map(|row| { row.map(|row| {
@@ -424,6 +448,116 @@ fn row_to_feedback(row: sqlx::postgres::PgRow) -> Result<AdminFeedback> {
}) })
} }
/// 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<String>,
}
/// 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<Vec<AdminCommunity>> {
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<Utc>,
/// Soft-deletion time, when deleted.
pub deleted_at: Option<DateTime<Utc>>,
/// Channel the event belongs to, if any.
pub channel_id: Option<Uuid>,
}
/// 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<Option<AdminEventPreview>> {
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::<Vec<u8>, _>("id")?),
author_pubkey: hex::encode(row.try_get::<Vec<u8>, _>("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 { impl Db {
/// List reports for the deployment-global read-only admin plane. /// List reports for the deployment-global read-only admin plane.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -453,10 +587,22 @@ impl Db {
.await .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")] #[datastore_span(name = "admin_get_report", system = "postgresql")]
pub async fn admin_get_report(&self, id: Uuid) -> Result<Option<AdminReportDetail>> { pub async fn admin_get_report(&self, id: Uuid) -> Result<Option<AdminReportDetail>> {
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<Option<AdminReportDetail>> {
get_report(&self.pool, id, &[]).await
} }
/// List feedback for the deployment-global read-only admin plane. /// 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<Option<AdminFeedback>> { pub async fn admin_get_feedback(&self, id: Uuid) -> Result<Option<AdminFeedback>> {
get_feedback(&self.pool, id).await 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<Vec<AdminCommunity>> {
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<Option<AdminEventPreview>> {
get_event_preview(&self.pool, community_id, id).await
}
} }
#[cfg(test)] #[cfg(test)]
@@ -620,7 +787,7 @@ mod postgres_tests {
.await; .await;
let report_id = insert_event_report(&pool, report_community, &event_id).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 .await
.expect("query report") .expect("query report")
.expect("report exists"); .expect("report exists");
@@ -707,6 +874,92 @@ mod postgres_tests {
delete_report_fixture(&pool, other_community).await; 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] #[tokio::test]
#[ignore = "requires Postgres"] #[ignore = "requires Postgres"]
async fn report_detail_has_no_message_for_non_event_target() { 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 community_id = insert_community(&pool, "pubkey-target").await;
let report_id = insert_pubkey_report(&pool, community_id).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 .await
.expect("query report") .expect("query report")
.expect("report exists"); .expect("report exists");
@@ -732,7 +985,7 @@ mod postgres_tests {
let missing_event_id = vec![8_u8; 32]; let missing_event_id = vec![8_u8; 32];
let report_id = insert_event_report(&pool, community_id, &missing_event_id).await; 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 .await
.expect("query report") .expect("query report")
.expect("report exists"); .expect("report exists");
@@ -799,7 +1052,7 @@ mod postgres_tests {
.await; .await;
set_report_status(&pool, community_id, report_id, "dismissed").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 .await
.expect("query report") .expect("query report")
.expect("report exists"); .expect("report exists");
@@ -839,7 +1092,7 @@ mod postgres_tests {
.await; .await;
set_report_status(&pool, community_id, report_id, "resolved").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 .await
.expect("query report") .expect("query report")
.expect("report exists"); .expect("report exists");
@@ -873,7 +1126,7 @@ mod postgres_tests {
) )
.await; .await;
let detail = get_report(&pool, report_id) let detail = get_report(&pool, report_id, &admin_hidden_kinds())
.await .await
.expect("query report") .expect("query report")
.expect("report exists"); .expect("report exists");

View File

@@ -229,7 +229,7 @@ pub async fn get_user_by_nip05(
/// ///
/// Without this, a search query of `"%"` would match every row (full table /// Without this, a search query of `"%"` would match every row (full table
/// scan) and `"_"` would act as a single-character wildcard. /// scan) and `"_"` would act as a single-character wildcard.
fn escape_like(input: &str) -> String { pub(crate) fn escape_like(input: &str) -> String {
input input
.replace('\\', "\\\\") .replace('\\', "\\\\")
.replace('%', "\\%") .replace('%', "\\%")

View File

@@ -1,6 +1,15 @@
//! Authentication and principal resolution for the deployment-admin API. //! 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 <base64 event>`. After //! Every request carries `Authorization: Nostr <base64 event>`. After
//! verifying the signature, timestamp, `u` tag, method tag, and (for //! 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 //! Config outranks DB: a `relay_operators` DB row for a config-backed
//! Operator pubkey is ignored; it never demotes a config grant. //! 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 //! `authorize()` checks no credential and returns
//! principal — mutations and staffing routes call //! [`AdminAccess::NetworkTrusted`]: whoever can reach the relay may view every
//! [`require_mutation_principal`], which 403s on `None`. //! 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::general_purpose::STANDARD as BASE64;
use base64::Engine as _; use base64::Engine as _;
@@ -67,8 +78,7 @@ pub enum AdminSource {
Db, Db,
} }
/// A resolved deployment-level principal, returned by [`authorize`] in nip98 /// A resolved deployment-level principal, carried by [`AdminAccess::Staff`].
/// mode.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AdminPrincipal { pub struct AdminPrincipal {
/// 32-byte pubkey (binary). /// 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 /// deserialize the same bytes. Never pass `None` for a body-bearing method in
/// nip98 mode — the `payload` sha256 tag would be skipped. /// nip98 mode — the `payload` sha256 tag would be skipped.
/// ///
/// Returns: /// `Ok(_)` is the view check: the caller may read. Writes and staffing then
/// - `Ok(Some(principal))` — nip98 mode (role resolved from roster). /// call [`AdminAccess::act`] or [`AdminAccess::operator`]. `Err(_)` means
/// - `Ok(None)` — disabled mode; reads pass, mutations 403 via /// authentication or authorization failed.
/// [`require_mutation_principal`].
/// - `Err(_)` — authentication or authorization failed.
pub async fn authorize( pub async fn authorize(
state: &AppState, state: &AppState,
headers: &HeaderMap, headers: &HeaderMap,
path_and_query: &str, path_and_query: &str,
method: &str, method: &str,
raw_body: Option<&[u8]>, raw_body: Option<&[u8]>,
) -> Result<Option<AdminPrincipal>, ApiError> { ) -> Result<AdminAccess, ApiError> {
let config = state let config = state
.config .config
.admin .admin
@@ -209,8 +217,8 @@ pub async fn authorize(
// Credential check first: an unauthenticated caller learns nothing about // Credential check first: an unauthenticated caller learns nothing about
// which Host or Origin the deployment expects. // which Host or Origin the deployment expects.
let (principal, nip98_event_id) = match &config.auth { let (access, nip98_event_id) = match &config.auth {
AdminAuth::Disabled => (None, None), AdminAuth::Disabled => (AdminAccess::NetworkTrusted, None),
AdminAuth::Nip98 => { AdminAuth::Nip98 => {
let full_path = format!("{ADMIN_API_PREFIX}{path_and_query}"); let full_path = format!("{ADMIN_API_PREFIX}{path_and_query}");
let (pubkey_bytes, event_id) = 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 // must not be able to consume replay slots at request rate. Only a
// request that clears authorization claims its event ID. // request that clears authorization claims its event ID.
let principal = resolve_admin_principal(state, pubkey_bytes).await?; 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?; 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<AdminAccess, ApiError> {
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<AdminAccess, ApiError> {
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. /// Resolve a 32-byte pubkey to an `AdminPrincipal` using config + DB.
@@ -333,25 +380,38 @@ pub async fn lookup_admin_principal(
Ok(None) Ok(None)
} }
/// Require that this request resolved a principal (nip98 mode) and return it. /// What [`authorize`] established about the caller.
/// Mutation and staffing routes are unavailable in disabled mode. #[derive(Debug, Clone)]
/// pub enum AdminAccess {
/// Returns the principal or a 403 if none was resolved. /// `disabled` mode: the network vouches for the caller; there is no
pub fn require_mutation_principal( /// identity. May view, never act or staff.
principal: Option<AdminPrincipal>, NetworkTrusted,
) -> Result<AdminPrincipal, ApiError> { /// `nip98` mode: a signed, rostered staff member.
principal Staff(AdminPrincipal),
.ok_or_else(|| ApiError::forbidden_with_message("mutations require BUZZ_ADMIN_AUTH=nip98"))
} }
/// Require that the principal holds Operator role. Used by staffing routes. impl AdminAccess {
pub fn require_operator(principal: &AdminPrincipal) -> Result<(), ApiError> { /// The act check: a write needs a signed staff member. Role-specific
if principal.role == AdminRole::Operator { /// checks run on the returned principal.
Ok(()) pub fn act(self) -> Result<AdminPrincipal, ApiError> {
} else { match self {
Err(ApiError::forbidden_with_message( Self::Staff(principal) => Ok(principal),
"staffing endpoints require operator role", 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<AdminPrincipal, ApiError> {
let principal = self.act()?;
if principal.role == AdminRole::Operator {
Ok(principal)
} else {
Err(ApiError::forbidden_with_message(
"staffing endpoints require operator role",
))
}
} }
} }

View File

@@ -12,16 +12,14 @@ use std::sync::Arc;
use axum::{ use axum::{
body::Bytes, body::Bytes,
extract::{Path, Query, State}, extract::{Path, Query, State},
http::{HeaderMap, StatusCode, Uri}, http::{HeaderMap, Method, StatusCode, Uri},
Json, Json,
}; };
use buzz_db::relay_admin_actions::{AdminActionRecord, DirectActionInput, DirectClaim}; use buzz_db::relay_admin_actions::{AdminActionRecord, DirectActionInput, DirectClaim};
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
use super::auth::{ use super::auth::{admin_role_str, authorize_write, lookup_admin_principal, AdminRole};
admin_role_str, authorize, lookup_admin_principal, require_mutation_principal, AdminRole,
};
use super::error::ApiError; use super::error::ApiError;
use super::{compute_timeout_until, decode_hex_pubkey, CommunityQuery}; use super::{compute_timeout_until, decode_hex_pubkey, CommunityQuery};
use crate::handlers::report_resolution::{drive_direct_action, ResolutionError}; use crate::handlers::report_resolution::{drive_direct_action, ResolutionError};
@@ -44,35 +42,38 @@ enum Kind {
pub(super) async fn ban( pub(super) async fn ban(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
method: Method,
uri: Uri, uri: Uri,
headers: HeaderMap, headers: HeaderMap,
Path(target): Path<String>, Path(target): Path<String>,
Query(q): Query<CommunityQuery>, Query(q): Query<CommunityQuery>,
body: Bytes, body: Bytes,
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> { ) -> Result<(StatusCode, Json<serde_json::Value>), 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( pub(super) async fn timeout(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
method: Method,
uri: Uri, uri: Uri,
headers: HeaderMap, headers: HeaderMap,
Path(target): Path<String>, Path(target): Path<String>,
Query(q): Query<CommunityQuery>, Query(q): Query<CommunityQuery>,
body: Bytes, body: Bytes,
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> { ) -> Result<(StatusCode, Json<serde_json::Value>), 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( pub(super) async fn delete_event(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
method: Method,
uri: Uri, uri: Uri,
headers: HeaderMap, headers: HeaderMap,
Path(target): Path<String>, Path(target): Path<String>,
Query(q): Query<CommunityQuery>, Query(q): Query<CommunityQuery>,
body: Bytes, body: Bytes,
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> { ) -> Result<(StatusCode, Json<serde_json::Value>), 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 /// 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)] #[allow(clippy::too_many_arguments)]
async fn direct_action( async fn direct_action(
state: Arc<AppState>, state: Arc<AppState>,
method: Method,
uri: Uri, uri: Uri,
headers: HeaderMap, headers: HeaderMap,
kind: Kind, kind: Kind,
@@ -108,12 +110,9 @@ async fn direct_action(
q: CommunityQuery, q: CommunityQuery,
body_bytes: Bytes, body_bytes: Bytes,
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> { ) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
let path = uri let principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes))
.path_and_query() .await?
.map_or_else(|| uri.path(), |pq| pq.as_str()); .act()?;
let principal = require_mutation_principal(
authorize(&state, &headers, path, "POST", Some(&body_bytes)).await?,
)?;
let body: DirectBody = serde_json::from_slice(&body_bytes) let body: DirectBody = serde_json::from_slice(&body_bytes)
.map_err(|_| ApiError::bad_request("invalid_body", "invalid JSON body"))?; .map_err(|_| ApiError::bad_request("invalid_body", "invalid JSON body"))?;
let target = decode_hex_pubkey(&target_hex)?; let target = decode_hex_pubkey(&target_hex)?;

View File

@@ -118,7 +118,8 @@ impl IntoResponse for ApiError {
} }
impl From<buzz_db::DbError> for ApiError { impl From<buzz_db::DbError> 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() Self::internal()
} }
} }

View File

@@ -1,23 +1,26 @@
//! Private deployment moderation API. //! Private deployment moderation API.
//! //!
//! Read routes are available in both auth modes (nip98, disabled). //! Every route is a view (moderation read), act (mutation) or operator
//! Mutation and staffing routes require an authenticated `nip98` principal //! (`/operators`) route; see [`auth`] for the rule. In `nip98` mode every route
//! (per-person, attributed to the resolved operator). //! 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 auth;
mod direct; mod direct;
mod error; mod error;
mod reads;
use std::sync::Arc; use std::sync::Arc;
use auth::{ use auth::{
admin_role_str, admin_source_str, authorize, require_mutation_principal, require_operator, admin_role_str, admin_source_str, authorize_read, authorize_write, AdminAccess, AdminRole,
AdminRole, AdminSource, AdminSource,
}; };
use axum::{ use axum::{
body::Bytes, body::Bytes,
extract::{Path, Query, State}, extract::{Path, Query, State},
http::{header, HeaderMap, HeaderValue, Uri}, http::{header, HeaderMap, HeaderValue, Method, Uri},
middleware::{self, Next}, middleware::{self, Next},
response::Response, response::Response,
routing::{delete, get, patch, put}, routing::{delete, get, patch, put},
@@ -40,9 +43,8 @@ pub(crate) use auth::admin_api_origin;
/// Build the deployment-admin routes. /// Build the deployment-admin routes.
/// ///
/// Read routes are available in all auth modes. /// Moderation reads (GET) are served in both auth modes. Mutations and the
/// Mutation routes (/reports/{id}/resolve, /feedback/{id}) and staffing routes /// staffing routes (/operators) require an authenticated `nip98` principal.
/// (/operators) require an authenticated `nip98` principal.
pub fn router(state: Arc<crate::state::AppState>) -> Router { pub fn router(state: Arc<crate::state::AppState>) -> Router {
Router::new() Router::new()
.route("/probe", get(probe)) .route("/probe", get(probe))
@@ -61,6 +63,10 @@ pub fn router(state: Arc<crate::state::AppState>) -> Router {
.route("/operators", get(list_operators)) .route("/operators", get(list_operators))
.route("/operators/{pubkey}", put(upsert_operator)) .route("/operators/{pubkey}", put(upsert_operator))
.route("/operators/{pubkey}", delete(delete_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/restrictions", get(list_member_restrictions))
.route("/members/{pubkey}/ban", delete(unban_member)) .route("/members/{pubkey}/ban", delete(unban_member))
.route("/members/{pubkey}/timeout", delete(untimeout_member)) .route("/members/{pubkey}/timeout", delete(untimeout_member))
@@ -116,12 +122,14 @@ struct ReportQuery {
limit: Option<i64>, limit: Option<i64>,
} }
fn limit(value: Option<i64>) -> Result<i64, ApiError> { /// A page size: `default` when absent, otherwise 1 to `max`, else 400
match value.unwrap_or(50) { /// `invalid_limit`.
value @ 1..=200 => Ok(value), fn limit(value: Option<i64>, default: i64, max: i64) -> Result<i64, ApiError> {
match value.unwrap_or(default) {
value if (1..=max).contains(&value) => Ok(value),
_ => Err(ApiError::bad_request( _ => Err(ApiError::bad_request(
"invalid_limit", "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( async fn probe(
State(state): State<Arc<crate::state::AppState>>, State(state): State<Arc<crate::state::AppState>>,
uri: Uri, uri: Uri,
method: Method,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Json<ProbeResponse>, ApiError> { ) -> Result<Json<ProbeResponse>, ApiError> {
let principal = authorize( let (auth_mode, role, source, can_act, can_staff) =
&state, match authorize_read(&state, &headers, &method, &uri).await? {
&headers, AdminAccess::NetworkTrusted => ("disabled", None, None, false, false),
uri.path_and_query() AdminAccess::Staff(p) => {
.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 can_staff = p.role == AdminRole::Operator; let can_staff = p.role == AdminRole::Operator;
( (
"nip98", "nip98",
@@ -187,9 +182,7 @@ async fn probe(
can_staff, can_staff,
) )
} }
}, };
None => return Err(ApiError::not_found()),
};
Ok(Json(ProbeResponse { Ok(Json(ProbeResponse {
status: "ok", status: "ok",
@@ -204,18 +197,11 @@ async fn probe(
async fn reports( async fn reports(
State(state): State<Arc<crate::state::AppState>>, State(state): State<Arc<crate::state::AppState>>,
uri: Uri, uri: Uri,
method: Method,
headers: HeaderMap, headers: HeaderMap,
Query(query): Query<ReportQuery>, Query(query): Query<ReportQuery>,
) -> Result<Json<Vec<buzz_db::admin_moderation::AdminReport>>, ApiError> { ) -> Result<Json<Vec<buzz_db::admin_moderation::AdminReport>>, ApiError> {
authorize( authorize_read(&state, &headers, &method, &uri).await?;
&state,
&headers,
uri.path_and_query()
.map_or_else(|| uri.path(), |pq| pq.as_str()),
"GET",
None,
)
.await?;
validate( validate(
query.status.as_deref(), query.status.as_deref(),
REPORT_STATUS_ALLOWLIST, REPORT_STATUS_ALLOWLIST,
@@ -246,7 +232,7 @@ async fn reports(
query.after, query.after,
query.before, query.before,
None, None,
limit(query.limit)?, limit(query.limit, 50, 200)?,
) )
.await?; .await?;
Ok(Json(items)) Ok(Json(items))
@@ -255,18 +241,11 @@ async fn reports(
async fn report_detail( async fn report_detail(
State(state): State<Arc<crate::state::AppState>>, State(state): State<Arc<crate::state::AppState>>,
uri: Uri, uri: Uri,
method: Method,
headers: HeaderMap, headers: HeaderMap,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<buzz_db::admin_moderation::AdminReportDetail>, ApiError> { ) -> Result<Json<buzz_db::admin_moderation::AdminReportDetail>, ApiError> {
authorize( authorize_read(&state, &headers, &method, &uri).await?;
&state,
&headers,
uri.path_and_query()
.map_or_else(|| uri.path(), |pq| pq.as_str()),
"GET",
None,
)
.await?;
state state
.db .db
.admin_get_report(id) .admin_get_report(id)
@@ -294,17 +273,10 @@ struct FeedbackSummary {
async fn feedback( async fn feedback(
State(state): State<Arc<crate::state::AppState>>, State(state): State<Arc<crate::state::AppState>>,
uri: Uri, uri: Uri,
method: Method,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Json<Vec<FeedbackSummary>>, ApiError> { ) -> Result<Json<Vec<FeedbackSummary>>, ApiError> {
authorize( authorize_read(&state, &headers, &method, &uri).await?;
&state,
&headers,
uri.path_and_query()
.map_or_else(|| uri.path(), |pq| pq.as_str()),
"GET",
None,
)
.await?;
let items = state let items = state
.db .db
.admin_list_feedback(100) .admin_list_feedback(100)
@@ -330,18 +302,11 @@ async fn feedback(
async fn feedback_detail( async fn feedback_detail(
State(state): State<Arc<crate::state::AppState>>, State(state): State<Arc<crate::state::AppState>>,
uri: Uri, uri: Uri,
method: Method,
headers: HeaderMap, headers: HeaderMap,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<buzz_db::admin_moderation::AdminFeedback>, ApiError> { ) -> Result<Json<buzz_db::admin_moderation::AdminFeedback>, ApiError> {
authorize( authorize_read(&state, &headers, &method, &uri).await?;
&state,
&headers,
uri.path_and_query()
.map_or_else(|| uri.path(), |pq| pq.as_str()),
"GET",
None,
)
.await?;
state state
.db .db
.admin_get_feedback(id) .admin_get_feedback(id)
@@ -353,18 +318,11 @@ async fn feedback_detail(
async fn feedback_attachment( async fn feedback_attachment(
State(state): State<Arc<crate::state::AppState>>, State(state): State<Arc<crate::state::AppState>>,
uri: Uri, uri: Uri,
method: Method,
headers: HeaderMap, headers: HeaderMap,
Path((id, sha256)): Path<(Uuid, String)>, Path((id, sha256)): Path<(Uuid, String)>,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {
authorize( authorize_read(&state, &headers, &method, &uri).await?;
&state,
&headers,
uri.path_and_query()
.map_or_else(|| uri.path(), |pq| pq.as_str()),
"GET",
None,
)
.await?;
if !is_sha256(&sha256) { if !is_sha256(&sha256) {
return Err(ApiError::not_found()); return Err(ApiError::not_found());
} }
@@ -485,6 +443,7 @@ fn compute_timeout_until(secs: u64) -> Result<DateTime<Utc>, ApiError> {
/// - delete/kick/ban/timeout: server-side enforcement state machine. /// - delete/kick/ban/timeout: server-side enforcement state machine.
async fn resolve_report( async fn resolve_report(
State(state): State<Arc<crate::state::AppState>>, State(state): State<Arc<crate::state::AppState>>,
method: Method,
uri: Uri, uri: Uri,
headers: HeaderMap, headers: HeaderMap,
Path(report_id): Path<Uuid>, Path(report_id): Path<Uuid>,
@@ -495,17 +454,9 @@ async fn resolve_report(
resolve_report_with_enforcement, ResolutionError, resolve_report_with_enforcement, ResolutionError,
}; };
let principal_opt = authorize( let principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes))
&state, .await?
&headers, .act()?;
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 body: ResolveReportBody = serde_json::from_slice(&body_bytes) let body: ResolveReportBody = serde_json::from_slice(&body_bytes)
.map_err(|_e| ApiError::bad_request("invalid_body", "invalid JSON body"))?; .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")); 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 let report_detail = state
.db .db
.admin_get_report(report_id) .admin_get_report_for_enforcement(report_id)
.await? .await?
.ok_or_else(ApiError::not_found)?; .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. /// records a durable `reopen` audit row. `409` if the report is not terminal.
async fn reopen_report( async fn reopen_report(
State(state): State<Arc<crate::state::AppState>>, State(state): State<Arc<crate::state::AppState>>,
method: Method,
uri: Uri, uri: Uri,
headers: HeaderMap, headers: HeaderMap,
Path(report_id): Path<Uuid>, Path(report_id): Path<Uuid>,
@@ -720,17 +673,9 @@ async fn reopen_report(
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
use buzz_db::relay_admin_actions::ReopenResult; use buzz_db::relay_admin_actions::ReopenResult;
let principal_opt = authorize( let principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes))
&state, .await?
&headers, .act()?;
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 body: ReopenReportBody = serde_json::from_slice(&body_bytes) let body: ReopenReportBody = serde_json::from_slice(&body_bytes)
.map_err(|_| ApiError::bad_request("invalid_body", "invalid JSON body"))?; .map_err(|_| ApiError::bad_request("invalid_body", "invalid JSON body"))?;
@@ -799,22 +744,15 @@ struct CancelReportBody {
/// `activeAction: null`. /// `activeAction: null`.
async fn cancel_report( async fn cancel_report(
State(state): State<Arc<crate::state::AppState>>, State(state): State<Arc<crate::state::AppState>>,
method: Method,
uri: Uri, uri: Uri,
headers: HeaderMap, headers: HeaderMap,
Path(report_id): Path<Uuid>, Path(report_id): Path<Uuid>,
body_bytes: Bytes, body_bytes: Bytes,
) -> Result<axum::http::Response<axum::body::Body>, ApiError> { ) -> Result<axum::http::Response<axum::body::Body>, ApiError> {
let principal_opt = authorize( let principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes))
&state, .await?
&headers, .act()?;
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 body: CancelReportBody = serde_json::from_slice(&body_bytes) let body: CancelReportBody = serde_json::from_slice(&body_bytes)
.map_err(|_| ApiError::bad_request("invalid_body", "invalid JSON body"))?; .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. /// Update product_feedback status. Requires nip98 auth.
async fn update_feedback_status( async fn update_feedback_status(
State(state): State<Arc<crate::state::AppState>>, State(state): State<Arc<crate::state::AppState>>,
method: Method,
uri: Uri, uri: Uri,
headers: HeaderMap, headers: HeaderMap,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
body_bytes: Bytes, body_bytes: Bytes,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
let principal_opt = authorize( let _principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes))
&state, .await?
&headers, .act()?;
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)?;
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)] #[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -938,20 +869,12 @@ struct OperatorEntry {
async fn list_operators( async fn list_operators(
State(state): State<Arc<crate::state::AppState>>, State(state): State<Arc<crate::state::AppState>>,
uri: Uri, uri: Uri,
method: Method,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Json<Vec<OperatorEntry>>, ApiError> { ) -> Result<Json<Vec<OperatorEntry>>, ApiError> {
let principal_opt = authorize( authorize_read(&state, &headers, &method, &uri)
&state, .await?
&headers, .operator()?;
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)?;
let config = state let config = state
.config .config
@@ -1020,23 +943,15 @@ struct UpsertOperatorBody {
/// Requires nip98 auth + Operator role. /// Requires nip98 auth + Operator role.
async fn upsert_operator( async fn upsert_operator(
State(state): State<Arc<crate::state::AppState>>, State(state): State<Arc<crate::state::AppState>>,
method: Method,
uri: Uri, uri: Uri,
headers: HeaderMap, headers: HeaderMap,
Path(pubkey_hex): Path<String>, Path(pubkey_hex): Path<String>,
body_bytes: Bytes, body_bytes: Bytes,
) -> Result<Json<OperatorEntry>, ApiError> { ) -> Result<Json<OperatorEntry>, ApiError> {
let principal_opt = authorize( let principal = authorize_write(&state, &headers, &method, &uri, Some(&body_bytes))
&state, .await?
&headers, .operator()?;
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)?;
// Canonicalize the path param once: validate it decodes to 32 bytes, then // Canonicalize the path param once: validate it decodes to 32 bytes, then
// lowercase it. Config-backed pubkeys are lowercased at parse, so the 409 // 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. /// Requires nip98 auth + Operator role.
async fn delete_operator( async fn delete_operator(
State(state): State<Arc<crate::state::AppState>>, State(state): State<Arc<crate::state::AppState>>,
method: Method,
uri: Uri, uri: Uri,
headers: HeaderMap, headers: HeaderMap,
Path(pubkey_hex): Path<String>, Path(pubkey_hex): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
let principal_opt = authorize( let principal = authorize_write(&state, &headers, &method, &uri, None)
&state, .await?
&headers, .operator()?;
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)?;
// Canonicalize the path param once (validate + lowercase) so the 409 check // 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; // 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<Utc>, Vec<u8>), ApiError> {
} }
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CommunityQuery { struct CommunityQuery {
community_host: String, community_host: String,
} }
@@ -1295,20 +1202,13 @@ struct RestrictionsQuery {
async fn list_member_restrictions( async fn list_member_restrictions(
State(state): State<Arc<crate::state::AppState>>, State(state): State<Arc<crate::state::AppState>>,
uri: Uri, uri: Uri,
method: Method,
headers: HeaderMap, headers: HeaderMap,
Query(query): Query<RestrictionsQuery>, Query(query): Query<RestrictionsQuery>,
) -> Result<Json<RestrictionsPage>, ApiError> { ) -> Result<Json<RestrictionsPage>, ApiError> {
authorize( authorize_read(&state, &headers, &method, &uri).await?;
&state,
&headers,
uri.path_and_query()
.map_or_else(|| uri.path(), |pq| pq.as_str()),
"GET",
None,
)
.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 cursor = query.cursor.as_deref().map(decode_cursor).transpose()?;
let community = community_for_host(&state, &query.community_host).await?; let community = community_for_host(&state, &query.community_host).await?;
@@ -1338,22 +1238,15 @@ async fn list_member_restrictions(
/// Requires nip98 auth. /// Requires nip98 auth.
async fn unban_member( async fn unban_member(
State(state): State<Arc<crate::state::AppState>>, State(state): State<Arc<crate::state::AppState>>,
method: Method,
uri: Uri, uri: Uri,
headers: HeaderMap, headers: HeaderMap,
Path(pubkey_hex): Path<String>, Path(pubkey_hex): Path<String>,
Query(query): Query<CommunityQuery>, Query(query): Query<CommunityQuery>,
) -> Result<axum::http::StatusCode, ApiError> { ) -> Result<axum::http::StatusCode, ApiError> {
let principal_opt = authorize( let principal = authorize_write(&state, &headers, &method, &uri, None)
&state, .await?
&headers, .act()?;
uri.path_and_query()
.map_or_else(|| uri.path(), |pq| pq.as_str()),
"DELETE",
None,
)
.await?;
let principal = require_mutation_principal(principal_opt)?;
let target_bytes = decode_hex_pubkey(&pubkey_hex)?; let target_bytes = decode_hex_pubkey(&pubkey_hex)?;
let community = community_for_host(&state, &query.community_host).await?; let community = community_for_host(&state, &query.community_host).await?;
@@ -1386,22 +1279,15 @@ async fn unban_member(
/// Requires nip98 auth. /// Requires nip98 auth.
async fn untimeout_member( async fn untimeout_member(
State(state): State<Arc<crate::state::AppState>>, State(state): State<Arc<crate::state::AppState>>,
method: Method,
uri: Uri, uri: Uri,
headers: HeaderMap, headers: HeaderMap,
Path(pubkey_hex): Path<String>, Path(pubkey_hex): Path<String>,
Query(query): Query<CommunityQuery>, Query(query): Query<CommunityQuery>,
) -> Result<axum::http::StatusCode, ApiError> { ) -> Result<axum::http::StatusCode, ApiError> {
let principal_opt = authorize( let principal = authorize_write(&state, &headers, &method, &uri, None)
&state, .await?
&headers, .act()?;
uri.path_and_query()
.map_or_else(|| uri.path(), |pq| pq.as_str()),
"DELETE",
None,
)
.await?;
let principal = require_mutation_principal(principal_opt)?;
let target_bytes = decode_hex_pubkey(&pubkey_hex)?; let target_bytes = decode_hex_pubkey(&pubkey_hex)?;
let community = community_for_host(&state, &query.community_host).await?; 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 /// Rostered as a config operator in `test_state()` so `authorized()` can
/// mint NIP-98 credentials that resolve to an Operator principal without a /// mint NIP-98 credentials that resolve to an Operator principal without a
/// DB lookup. /// DB lookup.
fn test_operator_keys() -> nostr::Keys { pub(super) fn test_operator_keys() -> nostr::Keys {
nostr::Keys::parse("0000000000000000000000000000000000000000000000000000000000000001") nostr::Keys::parse("0000000000000000000000000000000000000000000000000000000000000001")
.expect("valid test secret key") .expect("valid test secret key")
} }
@@ -1606,7 +1492,7 @@ mod postgres_tests {
nip98_state(vec![test_operator_keys().public_key().to_hex()]).await nip98_state(vec![test_operator_keys().public_key().to_hex()]).await
} }
async fn disabled_mode_state() -> Arc<crate::state::AppState> { pub(super) async fn disabled_mode_state() -> Arc<crate::state::AppState> {
let mut config = crate::config::Config::for_test(); // [FI-TRACE-ENV-RACE] let mut config = crate::config::Config::for_test(); // [FI-TRACE-ENV-RACE]
config.require_relay_membership = false; config.require_relay_membership = false;
config.redis_url = "redis://127.0.0.1:1".to_string(); config.redis_url = "redis://127.0.0.1:1".to_string();
@@ -1662,6 +1548,10 @@ mod postgres_tests {
"/feedback".to_string(), "/feedback".to_string(),
format!("/feedback/{id}"), format!("/feedback/{id}"),
format!("/feedback/{id}/attachments/{HASH}"), 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 /// Build an AppState that uses a real Postgres connection pool so HTTP
/// routes that hit the DB can commit and read back results. /// routes that hit the DB can commit and read back results.
async fn nip98_state_with_real_pool(pool: sqlx::PgPool) -> Arc<crate::state::AppState> { pub(super) async fn nip98_state_with_real_pool(
pool: sqlx::PgPool,
) -> Arc<crate::state::AppState> {
let mut config = crate::config::Config::for_test(); // [FI-TRACE-ENV-RACE] let mut config = crate::config::Config::for_test(); // [FI-TRACE-ENV-RACE]
config.require_relay_membership = false; config.require_relay_membership = false;
config.redis_url = "redis://127.0.0.1:1".to_string(); 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 /// Build a test AppState in nip98 mode with the given operator pubkeys
/// (populated in relay_operator_pubkeys config) and an AlwaysFreshReplayGuard. /// (populated in relay_operator_pubkeys config) and an AlwaysFreshReplayGuard.
async fn nip98_state(pubkeys: Vec<String>) -> Arc<crate::state::AppState> { pub(super) async fn nip98_state(pubkeys: Vec<String>) -> Arc<crate::state::AppState> {
nip98_state_with_replay(pubkeys, Arc::new(AlwaysFreshReplayGuard)).await 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 /// 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` /// path (e.g. `/reports`); this helper prefixes it with `ADMIN_API_PREFIX`
/// to match the canonical URL the auth layer constructs in production. /// 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}; use nostr::{EventBuilder, Kind, Tag};
let url = format!("https://admin.example{ADMIN_API_PREFIX}{path}"); let url = format!("https://admin.example{ADMIN_API_PREFIX}{path}");
let tags = vec![ let tags = vec![
@@ -3889,7 +3781,7 @@ mod postgres_tests {
/// Build a NIP-98 `Authorization: Nostr` header from an explicit raw tag /// 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 /// list, so a test can inject duplicate `u`/`method`/`payload` tags that the
/// typed helpers can't express. Signs a real kind-27235 event. /// typed helpers can't express. Signs a real kind-27235 event.
fn make_nostr_auth_raw_tags(keys: &nostr::Keys, tags: Vec<nostr::Tag>) -> String { pub(super) fn make_nostr_auth_raw_tags(keys: &nostr::Keys, tags: Vec<nostr::Tag>) -> String {
use base64::engine::general_purpose::STANDARD as BASE64; use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _; use base64::Engine as _;
use nostr::{EventBuilder, Kind}; use nostr::{EventBuilder, Kind};
@@ -4024,8 +3916,8 @@ mod postgres_tests {
} }
/// POST /reports/{id}/resolve in disabled mode → 403. Disabled mode is /// POST /reports/{id}/resolve in disabled mode → 403. Disabled mode is
/// always read-only: `authorize()` resolves no principal, so /// read-only: `authorize()` returns `NetworkTrusted`, so the act check
/// `require_mutation_principal` rejects every mutation with 403. /// rejects every mutation with 403.
#[tokio::test] #[tokio::test]
async fn mutation_routes_in_disabled_mode_return_403() { async fn mutation_routes_in_disabled_mode_return_403() {
let state = disabled_mode_state().await; 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] #[tokio::test]
#[ignore = "requires Postgres — moderator DB lookup"] #[ignore = "requires Postgres — moderator DB lookup"]
async fn moderator_cannot_access_staffing_endpoints() { async fn moderator_cannot_access_staffing_endpoints() {
// This test needs DB to resolve moderator role. let operator_keys = nostr::Keys::generate();
// Covered by negative-matrix integration test suite. 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<String>, 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. /// Config-backed pubkey upsert → 409 Conflict.
@@ -7316,6 +7308,197 @@ mod postgres_tests {
assert!(banned, "the stored author must be banned"); 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<Uuid>) =
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] #[tokio::test]
#[ignore = "requires Postgres — HTTP kick on a purged event report fails pre-claim without dirtying the report"] #[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() { async fn http_kick_on_event_report_with_missing_event_rejects_pre_claim() {

View File

@@ -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<Arc<AppState>>;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct CommunitiesQuery {
q: Option<String>,
cursor: Option<String>,
limit: Option<i64>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct CommunitiesPage {
items: Vec<AdminCommunity>,
next_cursor: Option<String>,
}
/// 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<String, ApiError> {
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<CommunitiesQuery>,
) -> Result<Json<CommunitiesPage>, 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<i64>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct MemberSearchItem {
pubkey: String,
display_name: Option<String>,
nip05: Option<String>,
avatar_url: Option<String>,
}
#[derive(Serialize)]
pub(super) struct MemberSearchPage {
items: Vec<MemberSearchItem>,
}
/// `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<MemberSearchQuery>,
) -> Result<Json<MemberSearchPage>, 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<String>,
nip05: Option<String>,
avatar_url: Option<String>,
about: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct MemberLookup {
pubkey: String,
profile: Option<MemberProfile>,
role: Option<String>,
banned: bool,
muted_until: Option<DateTime<Utc>>,
/// 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<bool>,
}
/// 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<bool, ApiError> {
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<String>,
Query(query): Query<CommunityQuery>,
) -> Result<Json<MemberLookup>, 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<String>,
Query(query): Query<CommunityQuery>,
) -> Result<Json<AdminEventPreview>, 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;

View File

@@ -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<String> {
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<AppState>, request: Request<Body>) -> (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<Body> {
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<Body> {
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<AppState>, 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<AppState>) {
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<String> {
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<Body> {
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:?})"
);
}
}
}

View File

@@ -39,16 +39,17 @@ pub enum ConfigError {
/// - `Moderator/Db` from the `relay_operators` table otherwise /// - `Moderator/Db` from the `relay_operators` table otherwise
/// - `None` → 403 (no fall-through role, ever) /// - `None` → 403 (no fall-through role, ever)
/// ///
/// Disabled mode is always read-only. NIP-98 mode is read-write per resolved /// Disabled mode serves every moderation read and refuses writes and staffing.
/// principal. /// NIP-98 mode is read-write per resolved principal.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum AdminAuth { pub enum AdminAuth {
/// Authentication disabled. The operator has explicitly asserted /// Authentication disabled. The operator has explicitly asserted
/// that the admin API is protected at the network layer (reverse proxy, /// that the admin API is protected at the network layer (reverse proxy,
/// VPN, firewall). `Host`/`Origin` checks remain active as defense-in-depth. /// VPN, firewall). `Host`/`Origin` checks remain active as defense-in-depth.
/// Selected by `BUZZ_ADMIN_AUTH=disabled`. /// Selected by `BUZZ_ADMIN_AUTH=disabled`.
/// Always read-only: `authorize()` resolves no principal for this mode, so /// Every moderation read is served to anyone who can reach the relay;
/// mutation and staffing routes always 403. /// `authorize()` returns `NetworkTrusted` with no identity, so write and
/// staffing routes always 403.
Disabled, Disabled,
/// NIP-98 HTTP Auth. Every request must carry an `Authorization: Nostr` /// NIP-98 HTTP Auth. Every request must carry an `Authorization: Nostr`
/// header containing a signed kind-27235 event. The authenticated pubkey /// 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 /// Deny-by-default deployment-admin configuration. Mutation and staffing routes
/// require a resolved principal (NIP-98 only); disabled mode is always /// require a resolved principal (NIP-98 only); disabled mode serves reads only.
/// read-only.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AdminConfig { pub struct AdminConfig {
/// Exact admin HTTP authority. /// Exact admin HTTP authority.
@@ -1287,9 +1287,13 @@ impl Config {
None | Some("") | Some("nip98") => AdminAuth::Nip98, None | Some("") | Some("nip98") => AdminAuth::Nip98,
Some("disabled") => { Some("disabled") => {
tracing::warn!( tracing::warn!(
"BUZZ_ADMIN_AUTH=disabled — the admin API is \ "BUZZ_ADMIN_AUTH=disabled — the admin API serves every \
unauthenticated; the operator has asserted that access is \ moderation read without authentication to anyone who can \
controlled at the network layer (reverse proxy, VPN, firewall)" 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 AdminAuth::Disabled
} }

View File

@@ -148,7 +148,7 @@ pub(crate) async fn recover_one(state: &Arc<AppState>, claim: StrandedActionClai
// Non-kick actions: always re-derive from the report. // Non-kick actions: always re-derive from the report.
// Pre-migration kick rows: re-derive so the convergence gate can use the // Pre-migration kick rows: re-derive so the convergence gate can use the
// result as a legacy-context fallback (see report_resolution.rs). // 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(Some(r)) => r,
Ok(None) => { Ok(None) => {
warn!(action_id = %action_id, "Action recovery: report not found"); warn!(action_id = %action_id, "Action recovery: report not found");

View File

@@ -46,8 +46,13 @@ BUZZ_S3_ADDRESSING_STYLE=path
# RELAY_OWNER_PUBKEY (implicit Operator fallback when operator list is empty), # RELAY_OWNER_PUBKEY (implicit Operator fallback when operator list is empty),
# and the relay_operators table (DB-managed Operator/Moderator roster). # and the relay_operators table (DB-managed Operator/Moderator roster).
# Dashboard requires a NIP-07 browser extension (nos2x or Alby). # Dashboard requires a NIP-07 browser extension (nos2x or Alby).
# BUZZ_ADMIN_AUTH=disabled — no auth. Use only behind a VPN or private ingress. # BUZZ_ADMIN_AUTH=disabled — read-only, no auth. Anyone who can reach the relay
# Relay logs a WARN on boot. # 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 # Token authentication was removed: BUZZ_ADMIN_TOKEN is ignored with a startup
# warning — remove it from the environment. # warning — remove it from the environment.
# Any unrecognised BUZZ_ADMIN_AUTH value aborts startup. # Any unrecognised BUZZ_ADMIN_AUTH value aborts startup.

View File

@@ -121,6 +121,7 @@ can be a break-glass root, silently discarding it would be a lockout.
| Capability | Operator | Moderator | | Capability | Operator | Moderator |
|---|---|---| |---|---|---|
| Read reports, feedback, attachment bytes | ✓ | ✓ | | Read reports, feedback, attachment bytes | ✓ | ✓ |
| Read community directory, member profiles, and events | ✓ | ✓ |
| Resolve reports (dismiss, escalate, delete, kick, ban, timeout) | ✓ | ✓ | | Resolve reports (dismiss, escalate, delete, kick, ban, timeout) | ✓ | ✓ |
| Update feedback status | ✓ | ✓ | | Update feedback status | ✓ | ✓ |
| Manage operator roster (`GET/PUT/DELETE /operators`) | ✓ | ✗ | | 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`) ### Disabled mode (`BUZZ_ADMIN_AUTH=disabled`)
Operators whose admin API is already protected at the network layer — for Disabled mode turns the admin API into a read-only window with no request
example by a corporate VPN such as WARP+Okta — can disable request authentication:
authentication entirely:
```text ```text
BUZZ_ADMIN_AUTH=disabled BUZZ_ADMIN_AUTH=disabled
@@ -163,23 +163,50 @@ BUZZ_ADMIN_AUTH=disabled
Only the exact value `disabled` is accepted. 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 BUZZ_ADMIN_AUTH=disabled — the admin API serves every moderation read without
asserted that access is controlled at the network layer 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 The `Host`/`Origin` checks remain active, but they are not authentication. The
detects that no credential is needed on first load (probe returns `200`) and dashboard detects that no credential is needed on first load (probe returns
renders directly. `200`) and renders read-only.
**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.
### Mode selection and error behaviour ### Mode selection and error behaviour
@@ -281,8 +308,12 @@ rolling.
For local review, run `just admin-seed` before `just admin`. `just admin` For local review, run `just admin-seed` before `just admin`. `just admin`
defaults to `BUZZ_ADMIN_AUTH=disabled`, so the dashboard renders without a defaults to `BUZZ_ADMIN_AUTH=disabled`, so the dashboard renders without a
credential. The seed command also uploads real image and diagnostic fixtures to credential. Unless the mode is exactly `nip98`, it binds the relay to
local MinIO. Feedback search and filters run over the bounded browser result `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 set. The feedback **status** control (`new`/`reviewed`/`archived`) is
server-backed: in `nip98` mode it `PATCH`es the relay and adopts the returned 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 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`
- `GET /api/admin/v1/feedback/:id/attachments/:sha256` - `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": "<token>"|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":
<timestamp>|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) ### Action routes (Operator and Moderator)
- `POST /api/admin/v1/reports/:id/resolve` - `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 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 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. 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=<host>[&limit=<1-200>][&cursor=<token>]` - `GET /api/admin/v1/members/restrictions?communityHost=<host>[&limit=<1-200>][&cursor=<token>]`
Lists active bans and timeouts, newest first. Response: Lists active bans and timeouts, newest first. Response: