mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
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>
506 lines
26 KiB
Markdown
506 lines
26 KiB
Markdown
# Deployment moderation dashboard
|
||
|
||
Buzz can expose a private, deployment-wide moderation dashboard from the existing
|
||
relay process. It shows open moderation reports and recent product feedback.
|
||
|
||
Configure `BUZZ_ADMIN_HOST` to activate the dashboard. A private ingress limits
|
||
access to the operator VPN or approved source IPs.
|
||
|
||
Required configuration:
|
||
|
||
```text
|
||
BUZZ_ADMIN_HOST=admin.example.com
|
||
BUZZ_ADMIN_WEB_DIR=/srv/buzz/admin-web
|
||
```
|
||
|
||
Plus one of the authentication modes below.
|
||
|
||
## Authentication
|
||
|
||
The admin API requires explicit authentication configuration. Setting only
|
||
`BUZZ_ADMIN_HOST` activates the dashboard in the default `nip98` mode. Configure
|
||
the mode with `BUZZ_ADMIN_AUTH`, which accepts `nip98` (default when unset) or
|
||
`disabled`.
|
||
|
||
`BUZZ_ADMIN_TOKEN` is no longer recognised: token authentication was removed.
|
||
Setting it is ignored with a startup warning, so a stale token variable cannot
|
||
silently run a deployment on a removed auth path — remove it from the environment
|
||
and use `nip98` (or `disabled` behind a network boundary).
|
||
|
||
### NIP-98 mode (`BUZZ_ADMIN_AUTH=nip98`, default)
|
||
|
||
Every `/api/admin/v1` request must carry a NIP-98 HTTP Auth header containing a
|
||
signed kind-27235 event. The signer's pubkey is resolved against a two-tier
|
||
principal model — **Operator** or **Moderator** — that grants capabilities
|
||
accordingly.
|
||
|
||
```text
|
||
BUZZ_ADMIN_AUTH=nip98
|
||
RELAY_OPERATOR_PUBKEYS=<64-char hex pubkey>[,<64-char hex pubkey>...]
|
||
```
|
||
|
||
- A malformed `RELAY_OWNER_PUBKEY` alongside `nip98` is a startup error (see
|
||
owner fallback below).
|
||
- `RELAY_OPERATOR_API_ORIGIN` is **not** required to run the admin console.
|
||
That origin is only used by the community-provisioning endpoints
|
||
(`POST /operator/communities`), which share the `RELAY_OPERATOR_PUBKEYS`
|
||
allowlist. When the pubkeys are set but the origin is not, the relay boots
|
||
with a `WARN` and provisioning requests fail closed at request time until the
|
||
origin is set — the admin console is unaffected.
|
||
|
||
#### Auto-discovery via NIP-11
|
||
|
||
When `BUZZ_ADMIN_HOST` is set, the relay advertises the admin API origin in its
|
||
NIP-11 relay-information document under an optional `admin_api` field:
|
||
|
||
```json
|
||
{ "admin_api": "https://admin.example.com" }
|
||
```
|
||
|
||
The value is the canonical origin `scheme://host[:port]` (no path), with the
|
||
scheme derived by the same loopback rule as `u`-tag verification (`http` for
|
||
`localhost`/`127.x`/`[::1]`, else `https`). The field is omitted entirely when no
|
||
admin surface is configured. Clients (such as the desktop console) read this to
|
||
auto-discover the admin endpoint instead of requiring manual URL entry. IPv6
|
||
admin hosts must be bracketed (`[::1]`, `[::1]:3000`); an unbracketed literal is
|
||
a startup error.
|
||
|
||
Each request requires:
|
||
|
||
```http
|
||
Authorization: Nostr <base64(JSON event)>
|
||
```
|
||
|
||
The event must be kind 27235, have a `u` tag matching the exact request URL
|
||
(including any query string, e.g.
|
||
`https://admin.example.com/api/admin/v1/reports?status=open`), a `method` tag
|
||
matching the HTTP method, a valid Schnorr signature, and a `created_at` within
|
||
±60 seconds of now. Body-bearing mutations (`POST`, `PUT`, `PATCH`) additionally
|
||
require a `payload` tag containing the SHA-256 hex digest of the raw request
|
||
body. A deployment-scoped replay guard rejects reused event IDs; Redis failure
|
||
fails closed.
|
||
|
||
Any auth failure (bad event, bad signature, expired, replay, unrecognised pubkey,
|
||
missing/incorrect `payload` tag on mutations, duplicate `Authorization` header)
|
||
returns `401` with `WWW-Authenticate: Nostr`. The dashboard's first-load probe
|
||
treats any non-`200` response as nip98 mode (only a `200` selects disabled
|
||
mode), so a credential is always required unless the relay explicitly serves the
|
||
unauthenticated surface.
|
||
|
||
The dashboard requires a NIP-07 browser extension (such as
|
||
[nos2x](https://github.com/fiatjaf/nos2x) or [Alby](https://getalby.com)). If
|
||
no extension is detected, the dashboard shows an installation screen. Once an
|
||
extension is present, each API request is automatically signed with the
|
||
principal's nostr key without any prompts.
|
||
|
||
#### Principal model: Operator and Moderator
|
||
|
||
The signer's pubkey is resolved to a principal with an effective role and a
|
||
source that describes how the grant was established:
|
||
|
||
| Resolution order | Role | Source |
|
||
|---|---|---|
|
||
| Pubkey is in `RELAY_OPERATOR_PUBKEYS` (config) | Operator | `config` |
|
||
| Pubkey equals `RELAY_OWNER_PUBKEY` **and** `RELAY_OPERATOR_PUBKEYS` is empty | Operator | `owner_fallback` |
|
||
| Pubkey has a row in the `relay_operators` DB table | Operator or Moderator | `db` |
|
||
| No grant found | — | 403 |
|
||
|
||
Config always outranks DB: a DB row for a config-backed pubkey is ignored and
|
||
never demotes the config grant. `None` never falls through as a role.
|
||
|
||
**Owner fallback** is an implicit Operator grant for self-hosters that do not yet
|
||
have an operator configured. It activates only when the configured
|
||
`RELAY_OPERATOR_PUBKEYS` list is empty, and it is evaluated from config at
|
||
request time — staffing the roster cannot make it flap. Once any pubkey is added
|
||
to `RELAY_OPERATOR_PUBKEYS`, the fallback deactivates. A malformed
|
||
`RELAY_OWNER_PUBKEY` is a startup error (not warn-and-ignore): once the owner key
|
||
can be a break-glass root, silently discarding it would be a lockout.
|
||
|
||
#### Capabilities by role
|
||
|
||
| Capability | Operator | Moderator |
|
||
|---|---|---|
|
||
| Read reports, feedback, attachment bytes | ✓ | ✓ |
|
||
| Read community directory, member profiles, and events | ✓ | ✓ |
|
||
| Resolve reports (dismiss, escalate, delete, kick, ban, timeout) | ✓ | ✓ |
|
||
| Update feedback status | ✓ | ✓ |
|
||
| Manage operator roster (`GET/PUT/DELETE /operators`) | ✓ | ✗ |
|
||
|
||
Capability checks are server-authoritative; the desktop console hides Staffing
|
||
tab controls for Moderators as a UX convenience only.
|
||
|
||
#### Roster management
|
||
|
||
Operators manage the roster via the staffing endpoints (`GET/PUT/DELETE
|
||
/operators/{pubkey}`). Staffing operations are only available in `nip98` mode.
|
||
|
||
Config-backed pubkeys (`RELAY_OPERATOR_PUBKEYS`, owner fallback) cannot be
|
||
modified through the API — `PUT` or `DELETE` against a config-backed pubkey
|
||
returns `409 Conflict`. A DB moderator row for a config-backed Operator pubkey is
|
||
ignored; it never demotes the config grant.
|
||
|
||
`GET /operators` returns every effective principal with its `effectiveRole` and
|
||
all contributing `sources` (`config`, `owner_fallback`, `db`).
|
||
|
||
**Last-operator protection.** When no config-backed operator is effective —
|
||
`RELAY_OPERATOR_PUBKEYS` is empty and owner fallback is inactive — the roster is
|
||
staffed entirely by DB `operator` rows. A demotion or delete that would remove
|
||
the final DB operator is rejected transactionally with `409 Conflict` ("add a
|
||
replacement operator first"); the row is left unchanged. The safe way to hand
|
||
off sole authority is the add-first transfer: `PUT` the replacement operator,
|
||
then demote or delete the outgoing one. Config-backed operators are unaffected —
|
||
while any config operator or owner fallback is effective, the DB roster may be
|
||
emptied freely because config still guarantees an operator.
|
||
|
||
### Disabled mode (`BUZZ_ADMIN_AUTH=disabled`)
|
||
|
||
Disabled mode turns the admin API into a read-only window with no request
|
||
authentication:
|
||
|
||
```text
|
||
BUZZ_ADMIN_AUTH=disabled
|
||
```
|
||
|
||
Only the exact value `disabled` is accepted.
|
||
|
||
| | `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 serves every moderation read without
|
||
authentication to anyone who can reach the relay: reports, feedback and
|
||
attachments, restrictions, the community directory, member profiles and any
|
||
stored message by ID, in every community. Writes and staffing are refused. Keep
|
||
the whole relay port private to people allowed to see all of that
|
||
```
|
||
|
||
The `Host`/`Origin` checks remain active, but they are not authentication. The
|
||
dashboard detects that no credential is needed on first load (probe returns
|
||
`200`) and renders read-only.
|
||
|
||
### Mode selection and error behaviour
|
||
|
||
`BUZZ_ADMIN_AUTH` accepts exactly `nip98` or `disabled`. Any other non-empty
|
||
value is a startup error (typo-proofing). `BUZZ_ADMIN_TOKEN` is no longer
|
||
recognised and its presence is ignored with a startup warning regardless of the
|
||
other variables:
|
||
|
||
| Combination | Result |
|
||
|---|---|
|
||
| `BUZZ_ADMIN_TOKEN` set (with or without `BUZZ_ADMIN_HOST`) | warn + ignore |
|
||
| `BUZZ_ADMIN_AUTH=nip98` with a malformed `RELAY_OWNER_PUBKEY` | startup error |
|
||
| `BUZZ_ADMIN_AUTH` junk value | startup error |
|
||
| `BUZZ_ADMIN_AUTH` without `BUZZ_ADMIN_HOST` | warn + ignore |
|
||
|
||
## Content Security Policy
|
||
|
||
Every admin-host response that carries the dashboard itself — the SPA document
|
||
on each admin route, the hashed `/assets/*` bundle, and admin-host `404`s — is
|
||
served with a Content Security Policy response header, `ADMIN_CSP` in
|
||
`crates/buzz-relay/src/router.rs`:
|
||
|
||
```text
|
||
default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'
|
||
```
|
||
|
||
It blocks inline and third-party script and restricts subresource and request
|
||
destinations to the same origin, which closes the direct paths an injected
|
||
script would use to exfiltrate credentials or data. It does not constrain
|
||
top-level navigation, so it is a containment layer, not a substitute for
|
||
keeping script off the origin. `blob:` is permitted for images only, for
|
||
attachment previews. It is a response header rather than a `<meta>` tag because
|
||
`frame-ancestors` is ignored in meta — that directive is the dashboard's
|
||
authoritative frame protection, superseding the `X-Frame-Options: DENY` the JSON
|
||
API sends. The policy applies to the admin host only; the public web bundle
|
||
keeps its own headers.
|
||
|
||
The exact admin `Host` and matching browser `Origin` are still required in both
|
||
auth modes, but they are defense-in-depth, not the primary access control. HTTPS
|
||
and a private ingress remain required: in nip98 mode the signed credential
|
||
travels in the `Authorization` header; in network-layer mode the VPN/firewall
|
||
boundary is the only access control.
|
||
|
||
When the UI runs in a separate pod, proxy `/api/admin/v1/*` to the relay while
|
||
preserving the admin `Host` header and (in nip98 mode) the client's
|
||
`Authorization` header. A `NetworkPolicy` grants the admin pod access to that
|
||
relay path.
|
||
|
||
## Operator migration
|
||
|
||
**Upgrading from a pre-auth release (Buzz prior to the introduction of this
|
||
`BUZZ_ADMIN_HOST` requirement):** any deployed relay with `BUZZ_ADMIN_HOST` set
|
||
boots in `nip98` mode after upgrade unless `BUZZ_ADMIN_AUTH=disabled` is set.
|
||
Choose the mode that fits your deployment:
|
||
|
||
- **Nostr principal model (default):** set `BUZZ_ADMIN_AUTH=nip98` (or leave it
|
||
unset) and populate `RELAY_OPERATOR_PUBKEYS` with at least one operator pubkey
|
||
(or rely on owner fallback if `RELAY_OWNER_PUBKEY` is already set) in your
|
||
deploy config, then roll the new version. In a single config rollout, both set
|
||
`BUZZ_ADMIN_AUTH=nip98` and add the operator pubkey(s) — never split these
|
||
across separate rollouts, as a mode-flip without operators configured leaves
|
||
no-one able to authenticate.
|
||
- **Network-layer mode (e.g. Block's `bb-public` behind WARP+Okta):** set
|
||
`BUZZ_ADMIN_AUTH=disabled` in your deploy config, then roll the new version.
|
||
|
||
**Upgrading from a token-mode release:** token authentication was removed.
|
||
Remove `BUZZ_ADMIN_TOKEN` from the environment — leaving it set is ignored with
|
||
a startup warning — and adopt `nip98` (populate `RELAY_OPERATOR_PUBKEYS`) or
|
||
`disabled` before rolling the new version.
|
||
|
||
**Upgrading from the previous `BUZZ_ADMIN_INSECURE_NO_AUTH=true` variable:**
|
||
replace it with `BUZZ_ADMIN_AUTH=disabled`. Behavior is identical; the old
|
||
variable is no longer recognised.
|
||
|
||
**Member restriction routes now take `communityHost`, not `communityId`:**
|
||
`GET /members/restrictions` and `DELETE /members/:pubkey/ban|timeout` name the
|
||
community by the relay host the client connected to, so the relay resolves
|
||
its own tenant (see [Member restriction routes](#member-restriction-routes-operator-and-moderator)).
|
||
There is no compatibility shim:
|
||
|
||
- Roll the relay with this change **before** any desktop build that uses it.
|
||
A new desktop against an old relay cannot list or lift restrictions.
|
||
- Scripts that call these routes with `communityId` must switch to
|
||
`communityHost`.
|
||
- Rolling the relay back to a `communityId` build breaks the desktop
|
||
Admin → Staffing → Restrictions UI until the relay is rolled forward again.
|
||
|
||
Relays without `BUZZ_ADMIN_HOST` are completely unaffected, except that a
|
||
lingering `BUZZ_ADMIN_TOKEN` now logs a startup warning and must be removed.
|
||
|
||
Any non-browser client of `/api/admin/v1` (monitoring probes, scripts, cron
|
||
jobs) must sign each request with a NIP-98 `Authorization: Nostr` header after
|
||
the upgrade, unless the deployment runs in `disabled` mode. The dashboard handles
|
||
itself. If a reverse proxy strips or rewrites `Authorization` headers, the
|
||
dashboard breaks post-upgrade in nip98 mode — check proxy configuration before
|
||
rolling.
|
||
|
||
## Local development
|
||
|
||
For local review, run `just admin-seed` before `just admin`. `just admin`
|
||
defaults to `BUZZ_ADMIN_AUTH=disabled`, so the dashboard renders without a
|
||
credential. Unless the mode is exactly `nip98`, it binds the relay to
|
||
`127.0.0.1` on the port from `BUZZ_BIND_ADDR` (default 3000), so
|
||
unauthenticated moderation reads stay local even when the value is padded or
|
||
misspelled; `BUZZ_ADMIN_AUTH=nip98` keeps `BUZZ_BIND_ADDR` as configured. The
|
||
seed command also uploads real image and diagnostic fixtures to local MinIO.
|
||
Feedback search and filters run over the bounded browser result
|
||
set. The feedback **status** control (`new`/`reviewed`/`archived`) is
|
||
server-backed: in `nip98` mode it `PATCH`es the relay and adopts the returned
|
||
status, so every operator sees the same state; in `disabled` mode it renders as
|
||
a read-only badge because the server rejects mutations.
|
||
|
||
## Routes
|
||
|
||
### Read routes (Operator and Moderator)
|
||
|
||
- `GET /api/admin/v1/probe`
|
||
- `GET /api/admin/v1/reports`
|
||
- `GET /api/admin/v1/reports/:id`
|
||
- `GET /api/admin/v1/feedback`
|
||
- `GET /api/admin/v1/feedback/:id`
|
||
- `GET /api/admin/v1/feedback/:id/attachments/:sha256`
|
||
|
||
### Community read routes (Operator and Moderator in `nip98`; unauthenticated in `disabled` mode)
|
||
|
||
The three member and event routes name the community by `communityHost` (the
|
||
relay's own host authority, host plus port when non-default), resolving it
|
||
through the same tenant binder that scopes live connections. A host that the
|
||
relay serves no live community for returns `400 unknown_community_host`. All
|
||
four reject unknown query parameters with `400` (axum's plain-text rejection,
|
||
not the JSON error envelope, before authentication). All four accept a signed
|
||
`HEAD` request (sign with method `HEAD`).
|
||
|
||
- `GET /api/admin/v1/communities?q=&cursor=&limit=`
|
||
Lists live communities (not archived, not deleted). Response:
|
||
`{"items": [{"id", "host", "icon"|null}], "nextCursor": "<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)
|
||
|
||
- `POST /api/admin/v1/reports/:id/resolve`
|
||
Body: `{"action": "delete|kick|ban|timeout|dismiss|escalate", "expirationSecs": <number>, "reason": "<string>", "requestId": "<uuid>"}`
|
||
`expirationSecs` required for `timeout`, rejected for all others.
|
||
Target/channel are always derived from server-owned report provenance.
|
||
Response: `{"status": "<terminal>", "activeAction": <action | null>}`. Enforcement
|
||
actions return the governing action record; `dismiss`/`escalate` return `null`.
|
||
|
||
**`reason` is PUBLIC.** For `delete`/`kick`/`ban`/`timeout` the string is
|
||
broadcast verbatim to the channel as the removal tombstone's public reason
|
||
**and** sent verbatim to the affected user in a moderation DM. It is not
|
||
sanitized, redacted, or mapped to a code. Never put private, internal, or
|
||
report-derived context in `reason` — write only text that is safe for the
|
||
room and the actioned user to read.
|
||
- `POST /api/admin/v1/reports/:id/reopen`
|
||
Body: `{"requestId": "<uuid>", "reason": "<string>"}`
|
||
Returns a terminal report (`resolved`, `dismissed`, or `escalated`) to `open` and
|
||
records a durable `reopen` audit row. Idempotent on `requestId`: a retry after the
|
||
report has been reopened (and possibly re-resolved) returns the same `200` without
|
||
re-reopening. Returns `{"status": "open"}` on success, `409` if the report is not
|
||
in a terminal state, `404` if it does not exist.
|
||
- `POST /api/admin/v1/reports/:id/cancel`
|
||
Body: `{"actionId": "<uuid>"}`
|
||
Cancels a pre-mutation `failed` enforcement action, returning the report to `open`.
|
||
Cancel is the only recovery path for a failed action. `actionId` fences the cancel
|
||
to exactly the action the client observed. Returns `{"status": "open", "activeAction":
|
||
<cancelled action>}` — the embedded record is the last look at that action, since a
|
||
subsequent detail read (report back to `open`) serves `activeAction: null`. Returns
|
||
`409` if the action is not cancellable (already cancelled, superseded, or past the
|
||
mutation point) — treat as "refresh detail".
|
||
- `PATCH /api/admin/v1/feedback/:id`
|
||
Body: `{"status": "new|reviewed|archived"}`
|
||
|
||
### Member restriction routes (Operator and Moderator)
|
||
|
||
All three name the community by `communityHost`: the relay's own host
|
||
authority (host, plus port when non-default), which the relay resolves to its
|
||
tenant through the same binder that scopes live connections. The caller's
|
||
local community ids are never accepted. A host the relay serves no community
|
||
for returns `400` with error `unknown_community_host`, never an empty result.
|
||
Unknown query parameters are rejected with `400` (axum's plain-text
|
||
rejection, before authentication).
|
||
|
||
- `GET /api/admin/v1/members/restrictions?communityHost=<host>[&limit=<1-200>][&cursor=<token>]`
|
||
Lists active bans and timeouts, newest first. Response:
|
||
`{"items": [...], "nextCursor": "<token>" | null}`. `limit` defaults to 200;
|
||
pass the prior page's `nextCursor` as `cursor` to continue. `400` on a missing
|
||
or unknown host, an out-of-range `limit`, or a malformed `cursor`.
|
||
- `DELETE /api/admin/v1/members/:pubkey/ban?communityHost=<host>`
|
||
Lifts an active ban. `204` on success, `409` if no active ban exists.
|
||
Mutation: requires `nip98` mode.
|
||
- `DELETE /api/admin/v1/members/:pubkey/timeout?communityHost=<host>`
|
||
Clears an active timeout. `204` on success, `409` if no active timeout exists.
|
||
Mutation: requires `nip98` mode.
|
||
|
||
### Staffing routes (Operator only)
|
||
|
||
- `GET /api/admin/v1/operators`
|
||
Returns every effective principal with its `effectiveRole` and all
|
||
contributing `sources` (`config`, `owner_fallback`, `db`).
|
||
- `PUT /api/admin/v1/operators/:pubkey`
|
||
Body: `{"role": "operator|moderator"}`
|
||
Returns `409` if the target is config-backed.
|
||
- `DELETE /api/admin/v1/operators/:pubkey`
|
||
Returns `409` if the target is config-backed.
|
||
|
||
Report reads accept optional `communityId`, `status`, `reportType`, `targetKind`,
|
||
`after`, `before`, `scope`, and `limit` parameters. Limits are capped at 200. Feedback is
|
||
a bounded newest-first summary from the existing product-feedback repository.
|
||
|
||
### Escalation-scoped default
|
||
|
||
`GET /reports` with no `status` parameter returns only `escalated` reports — the
|
||
escalation backstop the operator queue is meant to hold per `VISION_MODERATION`,
|
||
where the severe class is the platform's to review rather than the community's.
|
||
An explicit `status=<open|resolved|dismissed|escalated>` filter is always honored
|
||
as given. Full visibility across every status stays available for platform-safety
|
||
and legal review via `scope=all`, which lists reports regardless of status;
|
||
`scope` accepts only `all` and is ignored when an explicit `status` is present.
|
||
|
||
Reports whose category is `illegal` bypass community triage entirely: they are
|
||
ingested with `status=escalated` rather than `open`, so the severe class reaches
|
||
the operator backstop without waiting for a community admin to forward it (per
|
||
`VISION_MODERATION`, that class was never the community's to hold). Every other
|
||
category still lands `open`. Auto-escalation only sets the queue status — it
|
||
records no moderator decision and stamps no resolver, so an auto-escalated report
|
||
is indistinguishable downstream from an admin-escalated one: the reopen route
|
||
returns it to `open` on the same terms, keyed only on status, never on how the
|
||
report became escalated.
|
||
|
||
## Feedback attachment boundary
|
||
|
||
Feedback attachment bytes are available only through the feedback-scoped read
|
||
route (`GET /api/admin/v1/feedback/:id/attachments/:sha256`, listed under Read
|
||
routes above).
|
||
|
||
The route uses the same credential requirement (NIP-98 event, or network-layer
|
||
boundary in disabled mode), private-ingress, exact admin `Host`, and same-origin
|
||
boundary as the JSON API. It is not a generic media endpoint. The relay loads
|
||
the feedback row, derives its community from server-owned provenance, verifies
|
||
that host resolution still maps to the row's `community_id`, and requires the
|
||
requested SHA-256 to match both the `x` field and source-community `/media/` URL
|
||
in that row's persisted `imeta` tag. It then reads the tenant-scoped media
|
||
sidecar before accessing the shared content-addressed blob. Unknown feedback,
|
||
unreferenced hashes, malformed paths, and cross-community substitutions all
|
||
collapse to `404`.
|
||
|
||
Product feedback is deployment-global operator evidence: when its source community
|
||
is purged, the row's `community_id` is severed to `NULL` (the row survives, its
|
||
provenance does not). List and detail reads still return such rows with
|
||
`communityId` and `communityHost` as `null`. Their attachments, however, were
|
||
purged with the tenant, so the attachment route fails closed to `404` for any
|
||
severed feedback — there is no tenant to bind and no tenant-scoped media to serve.
|
||
|
||
Only `GET` and `HEAD` are routed. Community `/media/*` reads always require
|
||
Blossom authorization and relay membership; the browser receives no reusable
|
||
signed URL. Responses are uncached, `nosniff`,
|
||
governed by a restrictive CSP, streamed from object storage, and non-previewable
|
||
content retains attachment disposition. Successful reads produce a structured
|
||
trace containing feedback ID, community ID, and attachment hash, but no feedback
|
||
body or attachment URL.
|
||
|
||
The human trust boundary is the chosen auth mode plus the private admin ingress.
|
||
Disabled mode provides no per-operator identity; anyone admitted to the dashboard
|
||
can read attachments for feedback records they can access. NIP-98 mode provides
|
||
per-operator attribution and individual revocability. Per-person identity in
|
||
disabled mode requires authenticated operator identity at ingress/application
|
||
level (for example an Okta-injected identity header).
|