Files
buzz/docs/admin/README.md
Will Pfleger 836e7d0d06 feat(desktop): browse and moderate every community in the Admin Console (#8027)
The Admin Console only ever saw the community the desktop happened to be
connected to. With #8011's community reads, the console can now browse
every community on the relay and, for signed staff, moderate any of them
by name without switching connections. 🤖

## What changes

- **Communities tab.** Lists every live community on the relay,
searchable by host prefix with "Load more" paging. The connected
community is pinned at the top by exact host match. Report rows get a
community badge that opens that community.
- **Community page.** Reports, Restrictions, Members and Actions, each
scoped to the community on the page. The Actions form has no host field:
it always acts on the page's community, and the TypeScript host
normalization is gone from the Actions tab.
- **Native reads** (`admin_list_communities`, `admin_search_members`,
`admin_get_member`, `admin_get_event`). Every request validates the
explicit community host and NIP-98-signs the real method and URL.
Failures come back as a structured error (`status`, `bodyComplete`,
`bodyEmpty`, `code`), so the UI can tell an older relay's empty 404, a
coded `event_not_found` and a transport failure apart, and never says
"not found" unless the relay did.
- **Explicit-host lifts.** `admin_lift_restriction` sends the page's
community host, not the active relay's. It freezes the relay and signer
the intent was reviewed under, and refuses to send if either has
changed.
- **Pending actions survive navigation.** A panel-level
`DirectActionsProvider` holds the frozen intent, so switching
communities or tabs mid-confirm doesn't lose it. Only Discard drops it.
A signer change remounts the controller and drops it.
- **Fenced lookups.** Member and event lookups are keyed by origin,
signer, community, target and a generation counter, so a late answer for
one community can't unlock Review in another.
- **Shared member picker** (`MemberSearchResult`, also used by Add
Member), showing the truncated npub so members with the same name can be
told apart. Delete accepts a note link and previews the message before
Review. Relay staff are blocked before Review.
- **Disabled admin auth is read-only.** With `BUZZ_ADMIN_AUTH=disabled`
the Communities tab and community pages still render, and report badges
still open them, but every ban, timeout, delete, add-member and lift
control is disabled. The settings badge says the console is read-only
and that acting needs `nip98`. Staffing stays hidden.
- #7904's protections in the Actions tab are kept: frozen intent,
`requestId`, Retry, `notSent` and the key-backup guard.
- **Readable load errors.** Admin list and detail loads show the relay's
error message (for example "request is not authorized"), never the raw
JSON envelope.
- **Failed kicks reload the report.** A resolve that fails with
`enforcement_failed` reloads the report, so its failed action and
"Cancel & reopen" appear immediately.
- **Key backups never leave in a search.** Every admin API request URL
passes the NIP-49 key-backup guard before it is signed or sent:
community and legacy reads, mutations, the connection probe (before its
first, unsigned request) and feedback-attachment fetches. The community
and member search boxes, including the directory's Load more, refuse a
pasted `nsec` or `ncryptsec` anywhere in the input without calling the
relay.
- **`just admin-seed` for multi-community testing.** Besides the local
community, it seeds `beta.localhost:3000`, `gamma.localhost:3000` and 50
filler communities, with named members, a ban and a timeout in each
named community, a report with Kick available, and a message for the
delete preview. Rerunning it restores all of that to the starting state.
It refuses, writing nothing, while a moderation action is unfinished on
a seeded report.
- The bodyless `GET /events/{id}` preview is added to the events-URL
egress inventory. It sends no body, so it carries no key material.

## Tests

Native (Rust):
- `empty_404_and_405_read_as_complete_and_empty`,
`coded_404_carries_the_relay_code`,
`truncated_and_over_cap_bodies_are_incomplete`,
`transport_failure_has_no_status`: the structured read error tells an
older relay, a coded refusal, a cut-off body and a transport failure
apart.
- `a_401_retry_is_signed_by_the_same_keys`,
`lift_signs_the_send_and_401_retry_with_the_checked_keys`: the 401 retry
never switches keys.
- `read_urls_validate_the_explicit_host_and_encode_the_query`,
`lift_url_sends_the_page_host_not_the_active_relay`,
`list_url_carries_host_and_cursor_and_refuses_a_changed_relay`,
`lift_refuses_before_sending_on_signer_change_or_bad_host`: requests
target the explicit host and refuse to send after a relay or signer
change.

jsdom:
- `actions-pending-nav`: a pending intent survives a community change,
and only Discard drops it.
- `actions-fenced-lookup`, `fenced-load`: a late lookup for an old key
never shows under, or unlocks, the new one.
- `actions-same-ids`: the same pubkey and event id are read separately
in each community.
- `actions-community-change-drops-member`, `actions-page-host`,
`actions-success`, `actions-identity`: the form acts only in the page's
community, and confirm sends the frozen intent.
- `actions-preview-errors`, `actions-delete-link`,
`actions-staff-target`, `actions-disabled-auth`: preview errors, delete
by note link, the staff block, and community pages rendering read-only
with every action control disabled under disabled admin auth.
- `communities-directory`, `communities-unsupported`, `community-badge`,
`community-badge-disabled-auth`: the directory, older relays, and report
badges, which open community pages under disabled admin auth too.
- `restrictions-lift-signer-change`. The existing restriction tests now
run on the community page and use `admin_lift_restriction`.

Mutation checks: removing the pending-navigation guard turns
`actions-pending-nav` red, and dropping the host from the fence key
turns `actions-fenced-lookup`, `actions-same-ids` and `fenced-load` red.
Ungating restriction lifts on `canMutate` turns `actions-disabled-auth`
red.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-08 13:58:07 -04:00

26 KiB
Raw Blame History

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:

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.

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:

{ "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:

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 or Alby). 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:

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 404s — is served with a Content Security Policy response header, ADMIN_CSP in crates/buzz-relay/src/router.rs:

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). 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 PATCHes 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.

just admin-seed also seeds beta.localhost:3000, gamma.localhost:3000 and 50 filler-NN.localhost:3000 communities, named members, one ban and one timeout per named community, and one pre-signed message per named community for the delete preview. It prints the member npubs and message note/nevent ids to paste. Rerunning it restores the seeded state, including deleted messages, restrictions, kicked message authors and feedback status. It refuses, writing nothing, while a seeded report or admin action is still in progress; finish or cancel it first. To use it from the desktop admin console with just dev, add these to .env and restart just dev:

BUZZ_ADMIN_HOST=admin.localhost:3000
BUZZ_ADMIN_AUTH=nip98
RELAY_OPERATOR_PUBKEYS=<your dev app pubkey, hex>

Then set the admin origin in Settings → Admin to http://admin.localhost:3000.

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).