mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
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>
524 lines
26 KiB
Markdown
524 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.
|
||
|
||
`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`:
|
||
|
||
```bash
|
||
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).
|