Adds authenticated, role-based moderation to the relay admin API. On
`main` the admin API is read-only and gated only by `Host`/`Origin`
matching; this branch adds NIP-98 authentication, a two-tier
Operator/Moderator principal model, mutation and staffing endpoints, and
NIP-11 auto-discovery so clients never type the admin URL by hand.
## Authentication (`BUZZ_ADMIN_AUTH`)
`BUZZ_ADMIN_AUTH` accepts `nip98` or `disabled`. Leaving it unset
defaults to `nip98` (fail-secure). Configuration fails closed: any other
value aborts startup, while a lingering `BUZZ_ADMIN_TOKEN` is ignored
with a startup warning — token (bearer) authentication is not supported.
`Host`/`Origin` matching is retained in every mode as defense-in-depth.
- **`nip98`** (default) — per-request signed NIP-98 (kind 27235) events,
resolved to an Operator or Moderator principal with per-person
attribution and individual revocability. Read-write per resolved
principal.
- **`disabled`** — no credential; relies entirely on network-layer
controls (reverse proxy, VPN, firewall) and logs a `WARN` on every boot.
Always read-only: `authorize()` resolves no principal, so mutation and
staffing routes always `403`.
## Roles
Buzz has two independent authority axes after this change.
**Relay-level** roles (new here) are deployment-global: they act across
every community on the relay, through the admin API. **Community-level**
roles (pre-existing, unchanged by this PR) are tenant-scoped: they act
inside one community, through signed Nostr moderation commands.
### Relay level (new)
| Role | Description |
|---|---|
| **Operator** | Full control of the deployment's moderation surface:
read all reports, feedback, and attachments across every community;
resolve reports with enforcement (`delete`/`kick`/`ban`/`timeout`) or
decisions (`dismiss`/`escalate`); reopen and cancel; update feedback
status; and manage the Operator/Moderator roster via the staffing
endpoints. |
| **Moderator** | Day-to-day triage: everything an Operator can do
except staffing — cannot view or change the roster. |
How a pubkey acquires a relay role (resolution order; config always
outranks DB):
1. Listed in `RELAY_OPERATOR_PUBKEYS` → **Operator** (source `config`)
2. Equals `RELAY_OWNER_PUBKEY` while `RELAY_OPERATOR_PUBKEYS` is empty →
**Operator** (source `owner_fallback`, a break-glass grant for
self-hosters that deactivates once any operator is configured)
3. Row in the `relay_operators` table → **Operator** or **Moderator**
(source `db`, managed via the staffing endpoints)
4. No match → `403`
### Community level (pre-existing, unchanged)
| Role | Description |
|---|---|
| **Owner** (community) | Full authority within their community: every
moderation action (delete, kick, ban/unban, timeout/untimeout, resolve
reports, view queue) plus member, role, and invite management. No guard
rails. |
| **Admin** (community) | Same community-wide moderation capabilities as
owner, except an admin cannot ban or time out the owner or a fellow
admin — only the owner may action an admin. Manages members and invites;
only the owner grants the admin role. |
| **Member** (community) | Standard participant; no moderation
capability. |
| **Owner / Admin** (channel) | Channel-local authority only: delete
messages and kick users within their own channel. |
| **Member / Guest / Bot** (channel) | No moderation authority. |
There is no community-level Moderator tier in v1; relay-level Moderator
is the only role by that name.
## Escalation scoping
The operator report queue is an escalation backstop, not the community's
day-to-day triage surface (per `VISION_MODERATION`, the severe class is
the platform's to review rather than the community's). Two rules enforce
that:
- **Escalated-by-default listing.** `GET /reports` with no `status`
parameter returns only `escalated` reports. An explicit
`status=<open|resolved|dismissed|escalated>` filter is always honored as
given, and 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.
- **Auto-escalated `illegal` reports.** Member reports whose category is
`illegal` 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. 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.
## Principal resolution and NIP-98 admission
`resolve_admin_principal()` returns `AdminPrincipal { pubkey, role,
source }` per the resolution order above; `None` never falls through as
a role. Admission is ordered so the replay guard is a privilege, not a
public surface: signature/URL/method/payload-hash verification first,
roster check second, and only then is the deployment-scoped replay id
atomically consumed — a validly-signing but unrostered key never
allocates a replay slot. Redis failure fails closed.
## Report resolution, recovery, and enforcement provenance
`POST /reports/{id}/resolve` is a crash-safe enforcement state machine:
decision-only outcomes (`dismiss`/`escalate`) are a single
CAS-plus-audit transaction; enforcement
(`delete`/`kick`/`ban`/`timeout`) claims the report
(`open`→`processing`), runs the durable mutation, then finalizes — a
re-drive resumes at the step marker and converges to exactly-one
enforcement, fenced by a lease and an outbox claim token.
Person-directed enforcement on an `event`-kind report derives its target
from the stored event's author (server-owned truth, never the reporter's
`p` tag) via a single `derive_enforcement_target` shared by the HTTP
driver and the recovery worker. If the reported event was purged before
its author could be read, person-directed actions are rejected pre-claim
and the report stays `open`; `delete` needs only the event id and is
exempt.
`GET /reports/{id}` and the resolve response carry an `activeAction`
field surfacing the enforcement that actually executed — a report
dismissed after a reopen still reports the ban that ran. `POST
/reports/{id}/reopen` returns a terminal report to `open` (idempotent on
`requestId`). `POST /reports/{id}/cancel` is the sole recovery path for
a pre-mutation `failed` action, attributed via
`relay_admin_actions.cancelled_by`.
## Feedback
`GET /feedback` and `/feedback/{id}` survive a tenant purge: provenance
columns are severed to `NULL` rather than cascade-deleted, and the
attachment path fails closed to `404` on a severed row. `PATCH
/feedback/{id}` updates lifecycle `status`
(`new`/`reviewed`/`archived`).
## Staffing and probe
`GET/PUT/DELETE /operators/{pubkey}` are Operator-only; mutating a
config-backed pubkey returns `409 Conflict`. `GET /operators` returns
the union of config and DB principals with per-entry `source`. `GET
/probe` reports auth mode, role, source, `canAct`, and `canStaff` for
the desktop console.
## NIP-11 auto-discovery
The NIP-11 relay-information document gains an optional `admin_api`
field carrying the canonical admin origin (`scheme://host[:port]`, no
path), present iff `BUZZ_ADMIN_HOST` is set and omitted otherwise. The
scheme follows the same loopback rule as NIP-98 `u`-tag verification via
a shared `scheme_for_host` helper, so the advertised origin and the
origin the relay verifies against can never diverge.
## Operator API origin decoupling
`RELAY_OPERATOR_API_ORIGIN` is no longer required at boot when
`RELAY_OPERATOR_PUBKEYS` is set — it is used only by the
community-provisioning endpoints, which fail closed at request time
(with a boot-time `WARN`) until it is set. The admin console needs no
origin.
## Admin-web adaptation
The standalone `admin-web` dashboard signs each request as a NIP-98
event via a NIP-07 browser extension, discovers the auth mode with a
single unauthenticated probe (`200` → `disabled`, anything else →
`nip98`, fail-secure), and carries no token entry surface. Playwright
coverage exercises the NIP-98 and CSP paths.
## Security hardening
Three findings from security review are folded in:
- **Append-only roster audit.** `PUT`/`DELETE /operators/{pubkey}`
mutate the deployment-wide root of trust, but the upsert overwrites
`role`/`added_by` in place and the delete removes the only row — so a
grant→revoke sequence left no trace of who was ever granted or by whom.
Each mutation now writes an `relay_operator_audit` row (actor, target,
`grant`/`revoke`, pre-image `prev_role`, `new_role`, timestamp) inside
the same transaction as the mutation. A per-target transaction-scoped
advisory lock serializes concurrent mutations of the same pubkey before
the pre-image read, so the recorded `prev_role` is always the true
predecessor even under a concurrent-grant race. Chronology is keyed on a
`BIGINT GENERATED ALWAYS AS IDENTITY` `seq` column, not the wall clock:
the serializing lock guarantees insertion order and `seq` captures it,
so ordered reads (`ORDER BY seq`) follow the true privilege chain even
across a backward NTP step that a `clock_timestamp()` ordering would
invert. `created_at` (`clock_timestamp()`) is retained as informational
occurrence time only. Append-only by construction — no `UPDATE`/`DELETE`
path and no API surface. A no-op delete writes nothing.
- **`expirationSecs` overflow.** The timeout path built `Utc::now() +
Duration::seconds(secs as i64)` from an attacker-controlled `u64`:
`i64::MAX` panicked the handler, and a wrapped-negative magnitude minted
a *past* expiry that still passed validation. `compute_timeout_until`
now rejects zero, rejects magnitudes above a documented
`MAX_TIMEOUT_SECS` (365 days), and uses checked
`try_seconds`/`checked_add_signed` so no input can panic or produce a
past expiry — over-cap, zero, `i64::MAX`, and wrapping-negative inputs
all return a clean `4xx`.
- **Uppercase-hex config-backed bypass.** Config pubkeys are lowercased
at parse, but the `409` immutability check raw-string-compared the path
param while `decode_hex_pubkey` accepted uppercase — so `PUT
/operators/{UPPERCASE}` skipped the guard and wrote a shadow row for the
same 32 bytes. The validated param is now canonicalized (lowercased)
before the `409` check, DB write, `DELETE`, and response body.
## Migrations
- `0035_relay_operators.sql` — `relay_operators` roster table
(deployment-global), `actor_authority` on `moderation_actions`,
`processing` status plus `active_action_id` on `moderation_reports`,
`status` on `product_feedback`.
- `0036_relay_admin_actions.sql` — enforcement-action table with a
`request_id` idempotency key, a `step_marker` for crash recovery, and a
`cancelled_by` attribution column.
- `0037_relay_admin_action_lease.sql` — lease fencing for the action
worker.
- `0038_relay_admin_outbox_claim_token.sql` — fenced claim token on the
outbox worker.
- `0039_relay_operator_audit.sql` — append-only `relay_operator_audit`
trail for roster mutations (see Security hardening).
`docs/admin/README.md` documents the full principal model, NIP-98 event
requirements, capabilities by role, the startup error matrix, and the
discovery field.
## Production blast radius
A relay without `BUZZ_ADMIN_HOST` is completely unaffected — the admin
surface stays disabled and `BUZZ_ADMIN_AUTH` is ignored; a lingering
`BUZZ_ADMIN_TOKEN` logs a startup warning and must be removed. Where
`BUZZ_ADMIN_HOST` **is** set, unset `BUZZ_ADMIN_AUTH` defaults to
`nip98` (per-person signed auth); `BUZZ_ADMIN_AUTH=disabled` reproduces
`main`'s prior `Host`/`Origin`-only gating but is read-only (mutation
routes `403`). The five migrations add tables and columns without
touching existing data.
---
Related: [block/buzz#4768](https://github.com/block/buzz/pull/4768)
(desktop admin console consuming the `admin_api` field),
[squareup/bb-public#339](https://github.com/squareup/bb-public/pull/339)
(Phase 4 rollout config)
---------
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
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_PUBKEYalongsidenip98is a startup error (see owner fallback below). RELAY_OPERATOR_API_ORIGINis not required to run the admin console. That origin is only used by the community-provisioning endpoints (POST /operator/communities), which share theRELAY_OPERATOR_PUBKEYSallowlist. When the pubkeys are set but the origin is not, the relay boots with aWARNand 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 | ✓ | ✓ |
| 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:
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 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 populateRELAY_OPERATOR_PUBKEYSwith at least one operator pubkey (or rely on owner fallback ifRELAY_OWNER_PUBKEYis already set) in your deploy config, then roll the new version. In a single config rollout, both setBUZZ_ADMIN_AUTH=nip98and 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-publicbehind WARP+Okta): setBUZZ_ADMIN_AUTH=disabledin 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.
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 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.
Routes
Read routes (Operator and Moderator)
GET /api/admin/v1/probeGET /api/admin/v1/reportsGET /api/admin/v1/reports/:idGET /api/admin/v1/feedbackGET /api/admin/v1/feedback/:idGET /api/admin/v1/feedback/:id/attachments/:sha256
Action routes (Operator and Moderator)
-
POST /api/admin/v1/reports/:id/resolveBody:{"action": "delete|kick|ban|timeout|dismiss|escalate", "expirationSecs": <number>, "reason": "<string>", "requestId": "<uuid>"}expirationSecsrequired fortimeout, 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/escalatereturnnull.reasonis PUBLIC. Fordelete/kick/ban/timeoutthe 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 inreason— write only text that is safe for the room and the actioned user to read. -
POST /api/admin/v1/reports/:id/reopenBody:{"requestId": "<uuid>", "reason": "<string>"}Returns a terminal report (resolved,dismissed, orescalated) toopenand records a durablereopenaudit row. Idempotent onrequestId: a retry after the report has been reopened (and possibly re-resolved) returns the same200without re-reopening. Returns{"status": "open"}on success,409if the report is not in a terminal state,404if it does not exist. -
POST /api/admin/v1/reports/:id/cancelBody:{"actionId": "<uuid>"}Cancels a pre-mutationfailedenforcement action, returning the report toopen. Cancel is the only recovery path for a failed action.actionIdfences 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 toopen) servesactiveAction: null. Returns409if the action is not cancellable (already cancelled, superseded, or past the mutation point) — treat as "refresh detail". -
PATCH /api/admin/v1/feedback/:idBody:{"status": "new|reviewed|archived"}
Staffing routes (Operator only)
GET /api/admin/v1/operatorsReturns every effective principal with itseffectiveRoleand all contributingsources(config,owner_fallback,db).PUT /api/admin/v1/operators/:pubkeyBody:{"role": "operator|moderator"}Returns409if the target is config-backed.DELETE /api/admin/v1/operators/:pubkeyReturns409if 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).