mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
refactor(relay): NIP-98 admin auth with Operator/Moderator roles and NIP-11 discovery (#3777)
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>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Read-only deployment moderation dashboard
|
||||
# Deployment moderation dashboard
|
||||
|
||||
Buzz can expose a private, deployment-wide read-only dashboard from the existing
|
||||
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
|
||||
@@ -13,38 +13,356 @@ BUZZ_ADMIN_HOST=admin.example.com
|
||||
BUZZ_ADMIN_WEB_DIR=/srv/buzz/admin-web
|
||||
```
|
||||
|
||||
The relay requires the configured admin host and matching browser origin.
|
||||
Requests and responses are bounded and uncached. The deployment routes admin
|
||||
traffic through the private ingress.
|
||||
Plus one of the authentication modes below.
|
||||
|
||||
## Authentication
|
||||
|
||||
The admin API requires explicit authentication configuration. Setting only
|
||||
`BUZZ_ADMIN_HOST` activates the dashboard in the default `nip98` mode. Configure
|
||||
the mode with `BUZZ_ADMIN_AUTH`, which accepts `nip98` (default when unset) or
|
||||
`disabled`.
|
||||
|
||||
`BUZZ_ADMIN_TOKEN` is no longer recognised: token authentication was removed.
|
||||
Setting it is ignored with a startup warning, so a stale token variable cannot
|
||||
silently run a deployment on a removed auth path — remove it from the environment
|
||||
and use `nip98` (or `disabled` behind a network boundary).
|
||||
|
||||
### NIP-98 mode (`BUZZ_ADMIN_AUTH=nip98`, default)
|
||||
|
||||
Every `/api/admin/v1` request must carry a NIP-98 HTTP Auth header containing a
|
||||
signed kind-27235 event. The signer's pubkey is resolved against a two-tier
|
||||
principal model — **Operator** or **Moderator** — that grants capabilities
|
||||
accordingly.
|
||||
|
||||
```text
|
||||
BUZZ_ADMIN_AUTH=nip98
|
||||
RELAY_OPERATOR_PUBKEYS=<64-char hex pubkey>[,<64-char hex pubkey>...]
|
||||
```
|
||||
|
||||
- A malformed `RELAY_OWNER_PUBKEY` alongside `nip98` is a startup error (see
|
||||
owner fallback below).
|
||||
- `RELAY_OPERATOR_API_ORIGIN` is **not** required to run the admin console.
|
||||
That origin is only used by the community-provisioning endpoints
|
||||
(`POST /operator/communities`), which share the `RELAY_OPERATOR_PUBKEYS`
|
||||
allowlist. When the pubkeys are set but the origin is not, the relay boots
|
||||
with a `WARN` and provisioning requests fail closed at request time until the
|
||||
origin is set — the admin console is unaffected.
|
||||
|
||||
#### Auto-discovery via NIP-11
|
||||
|
||||
When `BUZZ_ADMIN_HOST` is set, the relay advertises the admin API origin in its
|
||||
NIP-11 relay-information document under an optional `admin_api` field:
|
||||
|
||||
```json
|
||||
{ "admin_api": "https://admin.example.com" }
|
||||
```
|
||||
|
||||
The value is the canonical origin `scheme://host[:port]` (no path), with the
|
||||
scheme derived by the same loopback rule as `u`-tag verification (`http` for
|
||||
`localhost`/`127.x`/`[::1]`, else `https`). The field is omitted entirely when no
|
||||
admin surface is configured. Clients (such as the desktop console) read this to
|
||||
auto-discover the admin endpoint instead of requiring manual URL entry. IPv6
|
||||
admin hosts must be bracketed (`[::1]`, `[::1]:3000`); an unbracketed literal is
|
||||
a startup error.
|
||||
|
||||
Each request requires:
|
||||
|
||||
```http
|
||||
Authorization: Nostr <base64(JSON event)>
|
||||
```
|
||||
|
||||
The event must be kind 27235, have a `u` tag matching the exact request URL
|
||||
(including any query string, e.g.
|
||||
`https://admin.example.com/api/admin/v1/reports?status=open`), a `method` tag
|
||||
matching the HTTP method, a valid Schnorr signature, and a `created_at` within
|
||||
±60 seconds of now. Body-bearing mutations (`POST`, `PUT`, `PATCH`) additionally
|
||||
require a `payload` tag containing the SHA-256 hex digest of the raw request
|
||||
body. A deployment-scoped replay guard rejects reused event IDs; Redis failure
|
||||
fails closed.
|
||||
|
||||
Any auth failure (bad event, bad signature, expired, replay, unrecognised pubkey,
|
||||
missing/incorrect `payload` tag on mutations, duplicate `Authorization` header)
|
||||
returns `401` with `WWW-Authenticate: Nostr`. The dashboard's first-load probe
|
||||
treats any non-`200` response as nip98 mode (only a `200` selects disabled
|
||||
mode), so a credential is always required unless the relay explicitly serves the
|
||||
unauthenticated surface.
|
||||
|
||||
The dashboard requires a NIP-07 browser extension (such as
|
||||
[nos2x](https://github.com/fiatjaf/nos2x) or [Alby](https://getalby.com)). If
|
||||
no extension is detected, the dashboard shows an installation screen. Once an
|
||||
extension is present, each API request is automatically signed with the
|
||||
principal's nostr key without any prompts.
|
||||
|
||||
#### Principal model: Operator and Moderator
|
||||
|
||||
The signer's pubkey is resolved to a principal with an effective role and a
|
||||
source that describes how the grant was established:
|
||||
|
||||
| Resolution order | Role | Source |
|
||||
|---|---|---|
|
||||
| Pubkey is in `RELAY_OPERATOR_PUBKEYS` (config) | Operator | `config` |
|
||||
| Pubkey equals `RELAY_OWNER_PUBKEY` **and** `RELAY_OPERATOR_PUBKEYS` is empty | Operator | `owner_fallback` |
|
||||
| Pubkey has a row in the `relay_operators` DB table | Operator or Moderator | `db` |
|
||||
| No grant found | — | 403 |
|
||||
|
||||
Config always outranks DB: a DB row for a config-backed pubkey is ignored and
|
||||
never demotes the config grant. `None` never falls through as a role.
|
||||
|
||||
**Owner fallback** is an implicit Operator grant for self-hosters that do not yet
|
||||
have an operator configured. It activates only when the configured
|
||||
`RELAY_OPERATOR_PUBKEYS` list is empty, and it is evaluated from config at
|
||||
request time — staffing the roster cannot make it flap. Once any pubkey is added
|
||||
to `RELAY_OPERATOR_PUBKEYS`, the fallback deactivates. A malformed
|
||||
`RELAY_OWNER_PUBKEY` is a startup error (not warn-and-ignore): once the owner key
|
||||
can be a break-glass root, silently discarding it would be a lockout.
|
||||
|
||||
#### Capabilities by role
|
||||
|
||||
| Capability | Operator | Moderator |
|
||||
|---|---|---|
|
||||
| Read reports, feedback, attachment bytes | ✓ | ✓ |
|
||||
| Resolve reports (dismiss, escalate, delete, kick, ban, timeout) | ✓ | ✓ |
|
||||
| Update feedback status | ✓ | ✓ |
|
||||
| Manage operator roster (`GET/PUT/DELETE /operators`) | ✓ | ✗ |
|
||||
|
||||
Capability checks are server-authoritative; the desktop console hides Staffing
|
||||
tab controls for Moderators as a UX convenience only.
|
||||
|
||||
#### Roster management
|
||||
|
||||
Operators manage the roster via the staffing endpoints (`GET/PUT/DELETE
|
||||
/operators/{pubkey}`). Staffing operations are only available in `nip98` mode.
|
||||
|
||||
Config-backed pubkeys (`RELAY_OPERATOR_PUBKEYS`, owner fallback) cannot be
|
||||
modified through the API — `PUT` or `DELETE` against a config-backed pubkey
|
||||
returns `409 Conflict`. A DB moderator row for a config-backed Operator pubkey is
|
||||
ignored; it never demotes the config grant.
|
||||
|
||||
`GET /operators` returns every effective principal with its `effectiveRole` and
|
||||
all contributing `sources` (`config`, `owner_fallback`, `db`).
|
||||
|
||||
**Last-operator protection.** When no config-backed operator is effective —
|
||||
`RELAY_OPERATOR_PUBKEYS` is empty and owner fallback is inactive — the roster is
|
||||
staffed entirely by DB `operator` rows. A demotion or delete that would remove
|
||||
the final DB operator is rejected transactionally with `409 Conflict` ("add a
|
||||
replacement operator first"); the row is left unchanged. The safe way to hand
|
||||
off sole authority is the add-first transfer: `PUT` the replacement operator,
|
||||
then demote or delete the outgoing one. Config-backed operators are unaffected —
|
||||
while any config operator or owner fallback is effective, the DB roster may be
|
||||
emptied freely because config still guarantees an operator.
|
||||
|
||||
### Disabled mode (`BUZZ_ADMIN_AUTH=disabled`)
|
||||
|
||||
Operators whose admin API is already protected at the network layer — for
|
||||
example by a corporate VPN such as WARP+Okta — can disable request
|
||||
authentication entirely:
|
||||
|
||||
```text
|
||||
BUZZ_ADMIN_AUTH=disabled
|
||||
```
|
||||
|
||||
Only the exact value `disabled` is accepted.
|
||||
|
||||
In this mode the relay logs a `WARN` on every startup:
|
||||
|
||||
```
|
||||
BUZZ_ADMIN_AUTH=disabled — the admin API is unauthenticated; the operator has
|
||||
asserted that access is controlled at the network layer
|
||||
```
|
||||
|
||||
The `Host`/`Origin` checks remain active as defense-in-depth. The dashboard
|
||||
detects that no credential is needed on first load (probe returns `200`) and
|
||||
renders directly.
|
||||
|
||||
**This mode relies entirely on the operator's network controls.** If the admin
|
||||
API is reachable by untrusted clients, the entire moderation and feedback dataset
|
||||
is exposed. Use nip98 mode instead.
|
||||
|
||||
When using a reverse proxy in this mode, document the requirement and consider a
|
||||
proxy-injected shared secret or signed identity header for additional assurance.
|
||||
|
||||
### Mode selection and error behaviour
|
||||
|
||||
`BUZZ_ADMIN_AUTH` accepts exactly `nip98` or `disabled`. Any other non-empty
|
||||
value is a startup error (typo-proofing). `BUZZ_ADMIN_TOKEN` is no longer
|
||||
recognised and its presence is ignored with a startup warning regardless of the
|
||||
other variables:
|
||||
|
||||
| Combination | Result |
|
||||
|---|---|
|
||||
| `BUZZ_ADMIN_TOKEN` set (with or without `BUZZ_ADMIN_HOST`) | warn + ignore |
|
||||
| `BUZZ_ADMIN_AUTH=nip98` with a malformed `RELAY_OWNER_PUBKEY` | startup error |
|
||||
| `BUZZ_ADMIN_AUTH` junk value | startup error |
|
||||
| `BUZZ_ADMIN_AUTH` without `BUZZ_ADMIN_HOST` | warn + ignore |
|
||||
|
||||
## Content Security Policy
|
||||
|
||||
Every admin-host response that carries the dashboard itself — the SPA document
|
||||
on each admin route, the hashed `/assets/*` bundle, and admin-host `404`s — is
|
||||
served with a Content Security Policy response header, `ADMIN_CSP` in
|
||||
`crates/buzz-relay/src/router.rs`:
|
||||
|
||||
```text
|
||||
default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'
|
||||
```
|
||||
|
||||
It blocks inline and third-party script and restricts subresource and request
|
||||
destinations to the same origin, which closes the direct paths an injected
|
||||
script would use to exfiltrate credentials or data. It does not constrain
|
||||
top-level navigation, so it is a containment layer, not a substitute for
|
||||
keeping script off the origin. `blob:` is permitted for images only, for
|
||||
attachment previews. It is a response header rather than a `<meta>` tag because
|
||||
`frame-ancestors` is ignored in meta — that directive is the dashboard's
|
||||
authoritative frame protection, superseding the `X-Frame-Options: DENY` the JSON
|
||||
API sends. The policy applies to the admin host only; the public web bundle
|
||||
keeps its own headers.
|
||||
|
||||
The exact admin `Host` and matching browser `Origin` are still required in both
|
||||
auth modes, but they are defense-in-depth, not the primary access control. HTTPS
|
||||
and a private ingress remain required: in nip98 mode the signed credential
|
||||
travels in the `Authorization` header; in network-layer mode the VPN/firewall
|
||||
boundary is the only access control.
|
||||
|
||||
When the UI runs in a separate pod, proxy `/api/admin/v1/*` to the relay while
|
||||
preserving the admin `Host` header. A `NetworkPolicy` grants the admin pod access
|
||||
to that relay path.
|
||||
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.
|
||||
|
||||
Read routes:
|
||||
## 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.
|
||||
|
||||
Relays without `BUZZ_ADMIN_HOST` are completely unaffected, except that a
|
||||
lingering `BUZZ_ADMIN_TOKEN` now logs a startup warning and must be removed.
|
||||
|
||||
Any non-browser client of `/api/admin/v1` (monitoring probes, scripts, cron
|
||||
jobs) must sign each request with a NIP-98 `Authorization: Nostr` header after
|
||||
the upgrade, unless the deployment runs in `disabled` mode. The dashboard handles
|
||||
itself. If a reverse proxy strips or rewrites `Authorization` headers, the
|
||||
dashboard breaks post-upgrade in nip98 mode — check proxy configuration before
|
||||
rolling.
|
||||
|
||||
## Local development
|
||||
|
||||
For local review, run `just admin-seed` before `just admin`. `just admin`
|
||||
defaults to `BUZZ_ADMIN_AUTH=disabled`, so the dashboard renders without a
|
||||
credential. The seed command also uploads real image and diagnostic fixtures to
|
||||
local MinIO. Feedback search and filters run over the bounded browser result
|
||||
set. The feedback **status** control (`new`/`reviewed`/`archived`) is
|
||||
server-backed: in `nip98` mode it `PATCH`es the relay and adopts the returned
|
||||
status, so every operator sees the same state; in `disabled` mode it renders as
|
||||
a read-only badge because the server rejects mutations.
|
||||
|
||||
## Routes
|
||||
|
||||
### Read routes (Operator and Moderator)
|
||||
|
||||
- `GET /api/admin/v1/probe`
|
||||
- `GET /api/admin/v1/reports`
|
||||
- `GET /api/admin/v1/reports/:id`
|
||||
- `GET /api/admin/v1/feedback`
|
||||
- `GET /api/admin/v1/feedback/:id`
|
||||
- `GET /api/admin/v1/feedback/:id/attachments/:sha256`
|
||||
|
||||
### Action routes (Operator and Moderator)
|
||||
|
||||
- `POST /api/admin/v1/reports/:id/resolve`
|
||||
Body: `{"action": "delete|kick|ban|timeout|dismiss|escalate", "expirationSecs": <number>, "reason": "<string>", "requestId": "<uuid>"}`
|
||||
`expirationSecs` required for `timeout`, rejected for all others.
|
||||
Target/channel are always derived from server-owned report provenance.
|
||||
Response: `{"status": "<terminal>", "activeAction": <action | null>}`. Enforcement
|
||||
actions return the governing action record; `dismiss`/`escalate` return `null`.
|
||||
|
||||
**`reason` is PUBLIC.** For `delete`/`kick`/`ban`/`timeout` the string is
|
||||
broadcast verbatim to the channel as the removal tombstone's public reason
|
||||
**and** sent verbatim to the affected user in a moderation DM. It is not
|
||||
sanitized, redacted, or mapped to a code. Never put private, internal, or
|
||||
report-derived context in `reason` — write only text that is safe for the
|
||||
room and the actioned user to read.
|
||||
- `POST /api/admin/v1/reports/:id/reopen`
|
||||
Body: `{"requestId": "<uuid>", "reason": "<string>"}`
|
||||
Returns a terminal report (`resolved`, `dismissed`, or `escalated`) to `open` and
|
||||
records a durable `reopen` audit row. Idempotent on `requestId`: a retry after the
|
||||
report has been reopened (and possibly re-resolved) returns the same `200` without
|
||||
re-reopening. Returns `{"status": "open"}` on success, `409` if the report is not
|
||||
in a terminal state, `404` if it does not exist.
|
||||
- `POST /api/admin/v1/reports/:id/cancel`
|
||||
Body: `{"actionId": "<uuid>"}`
|
||||
Cancels a pre-mutation `failed` enforcement action, returning the report to `open`.
|
||||
Cancel is the only recovery path for a failed action. `actionId` fences the cancel
|
||||
to exactly the action the client observed. Returns `{"status": "open", "activeAction":
|
||||
<cancelled action>}` — the embedded record is the last look at that action, since a
|
||||
subsequent detail read (report back to `open`) serves `activeAction: null`. Returns
|
||||
`409` if the action is not cancellable (already cancelled, superseded, or past the
|
||||
mutation point) — treat as "refresh detail".
|
||||
- `PATCH /api/admin/v1/feedback/:id`
|
||||
Body: `{"status": "new|reviewed|archived"}`
|
||||
|
||||
### 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`, and `limit` parameters. Limits are capped at 200. Feedback is
|
||||
`after`, `before`, `scope`, and `limit` parameters. Limits are capped at 200. Feedback is
|
||||
a bounded newest-first summary from the existing product-feedback repository.
|
||||
|
||||
For local review, run `just admin-seed` before `just admin`. 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 **Acted on** checkbox is
|
||||
stored in that browser's local storage.
|
||||
### 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:
|
||||
route (`GET /api/admin/v1/feedback/:id/attachments/:sha256`, listed under Read
|
||||
routes above).
|
||||
|
||||
- `GET /api/admin/v1/feedback/:id/attachments/:sha256`
|
||||
|
||||
The route uses the same private-ingress, exact admin `Host`, and same-origin
|
||||
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
|
||||
@@ -54,6 +372,13 @@ 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`,
|
||||
@@ -62,8 +387,9 @@ 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 remains the private admin ingress. VPN/source-IP
|
||||
admission is not per-operator identity. Anyone admitted to the dashboard can
|
||||
read attachments for feedback records they can access. Per-person attribution
|
||||
or revocation requires authenticated operator identity at ingress/application
|
||||
level; this endpoint deliberately does not claim to provide it.
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user