Files
buzz/docs/admin/README.md
Will Pfleger 8f8c4dfade feat(desktop): relay admin console for the /api/admin/v1 operator surface (#4768)
Adds a relay admin console under Settings — the single surface for
relay-level operator work — replacing the previously unreachable
moderation section. Operators authenticate with their own Nostr key over
NIP-98 — no browser extension, no bearer token — to view deployment-wide
moderation reports and product feedback, resolve/dismiss/escalate/reopen
reports, update feedback status, and manage the operator/moderator
staffing roster.

## Nav reachability

The old `moderation` section id was defined in `settingsSections` but
never wired into any group in `settingsNavGroups`, so the sidebar never
rendered it — unreachable since #1617. This PR replaces it with a
`relay-admin` section wired into the Communities nav group, pointing at
the relay admin console, and drops the separate `admin-console` section
id so a single surface owns relay-level trust & safety.

The Relay admin nav entry is always visible. Auth gates the panel
itself; the nav entry is a door, not a credential — hiding it behind
discovery state would strand an operator whose relay transiently fails
NIP-11 discovery with no path back to the connection settings.

## Discovery and connection

On mount the console reads the connected relay's NIP-11 document for the
optional `admin_api` field. When a valid origin is discovered **and its
host matches the connected relay's host** (case-insensitive, host
identity only), it is automatically saved and probed — the panel renders
immediately with no Save step required. A cross-host advertisement is
pre-fill only: it populates the manual origin field under Advanced and
is never saved or probed without an explicit Save, so a relay cannot
direct an unconsented NIP-98 signature at a third-party origin; residual
exposure on the same-host path is bounded by NIP-98's URL/method/payload
binding. Saving is only required for manual origin changes. The
advertised origin is untrusted input: revalidated through
`AdminOrigin::parse`, falling back to manual entry when absent or
invalid. A saved manual origin always takes precedence over discovery.

## Auth and transport (Rust, `src-tauri/src/commands/admin/`)

- `AdminOrigin` value object: validates scheme + host + optional port,
rejects credentials/path/query/fragment; `http://` only for loopback.
The relay side normalizes its configured admin host at config load and
compares inbound `Host`/`Origin` hosts case-insensitively.
- `AdminRoute` closed enum: no IPC surface accepts arbitrary URLs, so
the NIP-98-signed URL is byte-identical to the fetched URL.
- Dedicated no-redirect `reqwest` client as an SSRF guard: a relay `3xx`
is an error and the NIP-98 header never crosses origins.
- `admin_probe` signs `GET /probe` and returns a typed state
(`nip98Authorized` / `nip98Denied` / `disabled` / `notAdminApi` /
`networkOrIntercepted`). The response body is validated against the full
relay contract — `nip98Authorized` only when the complete NIP-98
invariant holds, `disabled` only on a coherent disabled-mode body; any
incoherent or unrelated JSON classifies as `notAdminApi`, so the console
never fails open at the network boundary. Unknown fields are tolerated
for forward compatibility.
- NIP-98 signing via `AppState::signing_keys()`, one retry on `401` with
a fresh signed event; response size bounded by a `Content-Length`
preflight and a streaming byte counter; per-pubkey origin storage with
atomic `0o600` writes.

## Relay admin console

The console is gated behind the relay `OPERATOR`/`MODERATOR` roles from
#3777; every `/api/admin/v1/*` route returns `403` to anyone else.
Community self-administration (`community-members` / Invites) and
in-channel enforcement (the 9040–9043 signed commands) are a separate
axis and are untouched. The previously-duplicate community report queue
(`ModerationQueueCard`, `moderationQueue.ts`, and three dead hooks) was
unreachable dead code and is deleted in its own commit;
`useBanMemberMutation` and the rest of `features/moderation/hooks.ts`
remain in use by the members sidebar and message menu.

## Console UI (TypeScript, `src/features/admin-console/`)

- `AdminConsoleSettingsCard`: discovery + probe flow, honest copy for
every probe state, manual origin under an `Advanced` disclosure at the
bottom. The role (operator/moderator) is rendered in the connection
status line as plain text ("Connected as operator") — no badge-shaped
elements above the tab row. The origin provenance (relay config /
database) appears inside the Advanced card as small muted text. Section
title changed to `Admin`.
- `AdminConsolePanel` with Reports / Feedback / Staffing tabs (Staffing
gated on `role === "operator"`); reports and feedback grouped by
community for cross-community triage.
- **Reports**: always requests `scope=all` so the existing
resolve/cancel/reopen controls can reach every status (`open`,
`processing`, `resolved`, `dismissed`, `escalated`). The relay's
omitted-scope default is `escalated`-only (the platform-safety
backstop); `scope=all` is explicit and scoped to this console. Full
action matrix per `target_kind` (event →
delete/kick/ban/timeout/dismiss/escalate; pubkey →
ban/timeout/dismiss/escalate; blob → dismiss/escalate); kick is
suppressed when the report carries no `channelId`. A `processing` row
stays navigable — enforcement state lives in the detail view. A `failed`
action (always pre-mutation) offers a single **Cancel & reopen** via
`POST /reports/{id}/cancel` with `{actionId}` fencing; there is no
client-side retry. `pending`/`enforcing` actions belong to the relay
recovery worker and offer no button; a `409` reloads detail. Lists
refetch on back-navigation after a mutation.
- **Report failure UX**: `reportErrorMessage()` strips 4xx/5xx status
prefixes and surfaces the relay's own error reason in the toast (e.g.
relay returns `"400: you cannot report this content"` → toast shows
`"you cannot report this content"`). Self-report blocking UI removed —
the relay has no self-report gate, and the failure was purely
client-side error surfacing.
- **Reason audience disclosure**: the Reason (optional) field shows
exact copy under the input based on selected action — Delete discloses
that reason is sent verbatim to the affected user and posted publicly in
the room; Kick/Ban/Timeout disclose affected-user only; Dismiss/Escalate
disclose reporter only. These mirror the actual relay notice paths.
- **Enforcement history**: the report detail carries the governing
action even after the report leaves `processing` — a report enforced
then reopened is `open` yet still shows its `succeeded` action as
executed history alongside the resolve form; a reopen does not un-happen
a ban. The resolve form gates on `open` status alone.
- **Reopen**: terminal reports (`resolved`/`dismissed`/`escalated`) can
be returned to `open` for re-triage via `POST /reports/{id}/reopen`.
Reopen never reverses applied enforcement, and the copy says so.
- **Feedback**: list, detail, and status control
(`new`/`reviewed`/`archived`) with a generation-fenced attachment
viewer. A purged source community severs provenance to `null` rather
than deleting the row; severed rows bucket under a "source community
removed" heading.
- **Staffing**: Add is create-only with display names resolved via
`useUsersBatchQuery` and npub cross-fade on hover
(`HoverStaffingIdentity`). An entered key that already appears in the
authoritative roster (config, owner-fallback, or DB) is rejected locally
with an inline duplicate-key message and no request is issued; a `409
Conflict` from add, role change, or remove surfaces the relay's parsed
error body via `adminErrorMessage(e)` — distinguishing config-backed key
conflicts from last-operator conflicts without HTTP-status inference.
Roster with `config`/`owner_fallback`/`db` source labels; config-backed
entries disable remove client-side. In-place role change via `<select>`
calling `putAdminOperator`; the relay responds with the effective
`OperatorEntry` (`pubkey`, `effectiveRole`, `sources`) in the same shape
as the roster list. Read-only badge for config-managed entries.
- **Timeout expiry**: `useTimeoutState` clears the store when derived
state is `INACTIVE` but the store is still active; the 1s tick is scoped
to entries with a known `expiresAtMs` and owned by
`ComposerTimeoutBanner` (mounted only while a timeout is active), while
`ChannelPane` subscribes to the boolean `useTimeoutActive()` — the pane
re-renders when a timeout starts or ends, not every second.
`useMembersSidebarModeration` ticks `nowMs` only while the sidebar is
open, the viewer can moderate, and some member has a future
`mutedUntil`; the tick after the last expiry stops the interval.
- Staffing refreshes the principal after a self-demotion or
self-removal: `AdminConsolePanel` passes `onSelfMutation` to
`StaffingTab`, which re-probes the saved origin so the role badge and
tab visibility update without a manual re-probe.
- Staffing mutation failures, including both 409 variants (config-backed
key vs. last operator), surface the relay's parsed error via
`adminErrorMessage`, so the last-operator recovery guidance is shown.
- Mutations confirm via `sonner` toasts; failures surface the relay's
parsed error `message`, not raw JSON. The `disabled` auth mode renders
read-only.

## Mutation idempotency

Resolve and reopen carry a per-attempt `requestId`. On first submit the
whole command (`{requestId, action, reason, expirationSecs}`) is frozen;
after an ambiguous failure (409, 5xx, transport error, truncated
response) the snapshot is retained, action/reason/duration controls are
locked, and the retry sends the exact same payload byte-for-byte. A
definitive pre-commit rejection (non-409 4xx with `bodyComplete: true`)
discards the snapshot, unlocks controls, and the corrected submit uses a
fresh payload. On success the toast derives from the authoritative
`AdminReportResolution` (`activeAction.action` when present; otherwise
`status` for decision-only outcomes) — never from the mutable form
selection.

The native mutation commands — including operator delete — reject with a
typed `AdminMutationError` carrying the relay's HTTP status
(`relayStatus`, `null` when no verdict exists) and a `bodyComplete` flag
that is `true` only when the full response body was read.

## Restriction routes contract change

`GET /members/restrictions` and `DELETE /members/{pubkey}/ban|timeout`
now take `communityHost` instead of `communityId`; the relay resolves
the tenant itself and returns `400 unknown_community_host` for an
unmapped host. This is a breaking contract change with no compatibility
shim: the relay must run this build before a desktop that uses it,
scripts sending `communityId` must switch, and rolling the relay back
breaks the desktop Restrictions UI. `docs/admin/README.md` documents the
three routes in the route inventory and carries the migration note.
Restriction calls also carry the relay the list was loaded from, and the
native side rejects a mismatch before building any URL.

## Hardening

- Saved admin origins are stored per pubkey and per relay host. The host
slug parses the relay URL, keeps the port-drop for DNS names, and
encodes IPv6 hosts canonically, so two IPv6 relays never share a file
and no IPv6 slug can match a DNS slug.
- NIP-11 admin discovery has its own 10s request deadline and reads
`/info` success and error bodies through the bounded reader with a 64
KiB cap (`Content-Length` is only a pre-check).
- `PUT /operators` builds its `OperatorEntry` response from the
validated body (`effectiveRole = role`, `sources = ["db"]`) instead of
re-reading the roster, so a concurrent change can't turn a committed
write into a `403`.

Related: #3777 (relay `OPERATOR`/`MODERATOR` role model + NIP-98 auth +
`admin_api` NIP-11 advertisement — provides the runtime and the
discovery field this console consumes)
Related:
[builderbot-platform-core-infrastructure#140](https://github.com/squareup/builderbot-platform-core-infrastructure/pull/140)
(prod admin ingress allowlist and NIP-98 rollout)


## Screenshots

Captured headless at `197f0ba0c` against the mock bridge (1280×720).

### Setup

**Origin setup — Advanced disclosure open, idle state**
![Relay admin panel with Advanced: admin origin disclosure open before
probe](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/02-origin-setup.png)

**Authorized console — all three tabs**
![Authorized relay console showing Connected as operator status and
Reports/Feedback/Staffing
tabs](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/03-origin-authorized.png)

### Reports

**Reports queue — open, processing, resolved across two relay groups**
![Reports tab showing community-grouped list with spam/open,
harassment/processing, illegal/resolved
rows](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/04-reports-queue.png)

**Open report detail — reporter, target, message snapshot, resolve
form**
![Open spam report detail with reported message content and
Delete/Kick/Ban/Timeout/Dismiss/Escalate action
buttons](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/05-report-detail-open.png)

**Processing report — PROCESSING/HARASSMENT, ban enforcing state**
![Processing harassment report detail showing enforcement state with ban
action in enforcing
status](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/06-report-enforcement.png)

**Resolve form — Delete selected, audience disclosure copy**
![Resolve form with Delete action selected showing "Sent verbatim to the
affected user and posted publicly in the room" audience
copy](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/07-resolve-form.png)

**Resolved report — enforcement history block**
![Resolved/illegal report detail showing Resolved by, Resolved at, and
Act ID fields from completed
enforcement](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/08-resolved-history.png)

### Feedback

**Feedback list — community grouping with status badges**
![Feedback tab showing relay.example.com group with new/reviewed items
and source-community-removed
group](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/09-feedback-list.png)

**Feedback detail — mutable status control (operator mode)**
![Feedback detail with new/reviewed/archived status tab control visible
for
mutation](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/10-feedback-mutable.png)

**Feedback detail — passive status badge (read-only/disabled mode)**
![Feedback detail in disabled mode showing passive "Status NEW" badge
with no mutation
control](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/11-feedback-readonly.png)

### Staffing

**Staffing roster — config/owner\_fallback/db source labels**
![Staffing tab showing three operators with config, owner_fallback, and
db source
labels](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/12-staffing-roster.png)

**Add form — new pubkey filled, ready to submit**
![Staffing add form with a new 64-hex pubkey entered and moderator role
selected](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/13-staffing-add.png)

**Duplicate rejection — inline error for existing principal**
![Staffing add form showing "Already an operator: operator. Use Remove
to revoke before re-adding with a different role." error for an existing
config-backed
operator](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/14-staffing-dup.png)

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Signed-off-by: Alia <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Alia <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@buzz.block.builderlab.xyz>
2026-09-24 17:34:25 -04:00

429 lines
21 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 | ✓ | ✓ |
| 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`)
Operators whose admin API is already protected at the network layer — for
example by a corporate VPN such as WARP+Okta — can disable request
authentication entirely:
```text
BUZZ_ADMIN_AUTH=disabled
```
Only the exact value `disabled` is accepted.
In this mode the relay logs a `WARN` on every startup:
```
BUZZ_ADMIN_AUTH=disabled — the admin API is unauthenticated; the operator has
asserted that access is controlled at the network layer
```
The `Host`/`Origin` checks remain active as defense-in-depth. The dashboard
detects that no credential is needed on first load (probe returns `200`) and
renders directly.
**This mode relies entirely on the operator's network controls.** If the admin
API is reachable by untrusted clients, the entire moderation and feedback dataset
is exposed. Use nip98 mode instead.
When using a reverse proxy in this mode, document the requirement and consider a
proxy-injected shared secret or signed identity header for additional assurance.
### 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. 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`
### 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.
- `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).