Files
buzz/docs/admin/README.md
Wes b6aa5539e3 feat(mobile): account onboarding, key recovery and hosted reconnect (#8375)
## Behavior

Adds optional mobile Builderlab sign-in, identity backup/recovery,
community discovery/creation, and account-aware reconnect. **Builderlab
features are disabled by default:** when `BUZZ_AUTH_DOMAINS` is unset or
empty, account login, creation, backup/recovery, reconnect prompts, and
background account restoration are hidden/disabled. QR/code pairing,
links, and invites retain Nostr-only entry. Saved account state is
retained for later opt-in.


`--dart-define=BUZZ_AUTH_DOMAINS=communities.buzz.xyz,communities.public.builderlabstaging.xyz`
opts those DNS domains and their subdomains into required account setup.
The same policy gates pairing, deep links, durable community admission,
and reconnect. A nonempty list enables Builderlab features;
`BUZZ_BUILDERLAB_URL` alone does not. Enabled flows still require their
backend services. Existing Nostr relay authentication remains mandatory.

`BUZZ_HOSTED_RELAY_AUTH=true` enables scoped relay assertions and
reconnect prompts only for configured authentication domains. Configure
assertion issuance and each community's issuer binding before enabling
assertions; enable relay enforcement last. Account credentials stay at
Builderlab. Wrong-account recovery preserves keys; failed writes are not
replayed; authentication is bounded by request deadlines.

Hosted flows preserve keys across cancellation/restart, reconcile
interrupted creation/invitation acceptance, and support manual nsec file
import/export. Account identities are scoped to backend and
authenticated subject; reusing an existing local key for an unbound
account requires an explicit selection. Recovery reconciles the
advertised and recovered inventories before automatic selection.
Successful self-hosted admission retires its pending key, and iOS
startup removes interrupted export files. Adds authenticated relay `GET
/operator/communities/joined` for the companion Builderlab membership
endpoint.

## Validation

- Follow-up review repair at `66a12ac92`: self-hosted entry presents
readable choices and import despite incomplete enumeration, while
generation still requires a complete empty inventory; explicit selection
uses exact-key lookup. Consuming a value-equal duplicate now advances
dispatch. Reconnect requires an existing account binding or a
subject-scoped backup receipt before signing an assertion, and pins the
subject inside the serialized account request. An empty different
account is refused without sending key proof. Focused checks: configured
domains 58 passed; default 49 passed, 9 hosted-only skips. Separate
isolated mutations fail the incomplete-inventory sheet, exact-key reuse,
duplicate dispatch and reconnect journeys (including an
empty-account-only bypass); restored controls pass. Evidence uses
synthetic storage/network boundaries, not physical Android or live
backend association. All mandatory commit/push hooks passed; fresh
GitHub CI is pending.

- Bounded review repair at `d3cc0003f`: incomplete secure-storage
enumeration now blocks automatic identity generation while explicit
known-key reads retain their fallback; declined/failed onboarding links
release the queue; HTTP 429/5xx keeps Retry regardless of response
encoding. Default app-gate coverage exercises a bind link followed by
ordinary community entry. Focused checks: configured domains 47 passed;
default 38 passed, 9 hosted-only skips. Removing the three production
fixes in an isolated source/Git/build copy fails the
identity-resolution, declined/failed queue, and Retry journey
assertions; clean and restored controls pass. Storage and HTTP failures
are synthetic; physical Android and live outage behavior were not
exercised. Published after all mandatory commit/push hooks passed
(mobile analysis/tests, branch-skew and file-size gates); fresh GitHub
CI remains pending.

- Existing whole-PR source review cleared the reconnect defects at
`fb15d5b82`.
- Domain-policy increment: focused environment, entry, app-gate, pairing
and onboarding journeys pass with empty and configured domains. Existing
pairing provider/page tests pass. Changed Dart files analyze cleanly.
- Isolated source/Git/build mutation: forcing paired credentials through
hosted login fails the default-build journey; clean and restored
controls pass. CI now runs configured-domain journeys in addition to the
default-build suite.
- Integrated main through `5b433d929` at `0067c069c`, including its
pinned Flutter upgrade. Preserved the PR welcome UI with the updated
transition API and added Material surfaces around onboarding
selection/consent rows. Focused post-merge runs pass in both
configurations; all mandatory local push gates passed (mobile
analysis/tests, Rust tests, desktop-native checks, branch-skew and
file-size checks).

- Merge-review repair at `a315bce11`: focused identity/storage/admission
and widget journeys pass, including account switching with an empty or
already-bound second account, explicit reuse, and self-hosted
removal/rejoin with another key. Default-domain journeys pass too
(hosted-only cases intentionally skip there).
- Isolated source/Git/build mutation checks: bypassing account scoping,
reuse consent, pending-key retirement, or recovery inventory
reconciliation fails the relevant journey/invariant assertions; clean
and restored controls pass.
- Native `RunnerTests/IdentityFilePluginTests`: both tests pass on the
iPhone 18 Pro iOS 27 simulator with an isolated app identifier. At
`24a0dcaac`, the picker journey seeds residue in Library/Caches,
matching path_provider_foundation, and verifies plugin startup removes
it while preserving an unrelated file. The earlier tmp-only fixture did
not exercise the actual export location. This is synthetic native
cleanup/picker evidence, not a real terminated-share or live account
round trip.

- Follow-up: Caches and tmp cleanup; fallback to individually known
identity slots when Android bulk enumeration fails; explicit replacement
of a refused self-hosted key preserves its original before swapping the
retry slot. Configured-domain journeys/invariants: 34 passed; default:
31 passed, 3 hosted-only skips. Isolated enumeration/retry mutations
fail with passing controls. Bulk-read fallback preserves but cannot
enumerate arbitrary other-account slots until bulk reads recover.

- Configured-domain CI exposed a stale pairing-status assertion after
account scoping. The repaired test checks the admission mismatch,
preserved account/transfer keys, and no admitted community. The
configured-domain CI selection passes locally (56 tests); removing the
production mismatch guard fails the updated test, with clean/restored
controls passing. This is synthetic coordinator evidence, not physical
QR acceptance.

- Final review repair (`40e1e6101`): native iOS export creation uses
atomic complete file protection; normal and restart cleanup remain.
Account/policy/claim HTTP uses one 30-second total deadline with request
abort and body cancellation. Removed the unrelated NIP-FA spec edit.
- Focused account/admission/export tests: 25 pass; configured-domain
account/admission/onboarding journeys: 33 pass. Isolated deadline and
native-dispatch mutations fail, with passing clean/restored controls.
Native export/picker tests: 2 pass on iOS 27 simulator. The real
file-protection attribute assertion is physical-device-only because
simulator provides no Data Protection attribute; locked-device
protection and interrupted real sharing have not been observed.

- Opt-in UI: configured-domain CI selection passes (57 tests); default
selection passes (7 tests, 10 hosted-only skips); URL-only plus
whitespace/empty domains passes (6 tests, 9 hosted-only skips). Changed
Dart files analyze cleanly. Independent mutations that unconditionally
initialize the coordinator in the app gate, welcome entry, or link form
fail the default journey; clean/restored controls pass.
- Native welcome screens exercised on iPhone 18 Pro / iOS 27 simulator
in default and configured builds: default exposes community link / QR /
pairing code only; configured build additionally exposes Builderlab
connection and community creation. No live hosted login was exercised in
this check.

- Delivery at `e48fe73a9`: all mandatory push hooks passed (full mobile
analysis/tests, Rust tests, desktop-native checks, branch and file-size
checks). GitHub checks had not yet populated at the immediate post-push
snapshot.

## Review disposition

| Feedback | Disposition |
| --- | --- |
| Explicit self-hosted entry under unreadable inventory, duplicate
invite dispatch, reconnect cross-account binding
([entry](https://github.com/block/buzz/pull/8375#discussion_r4318421622),
[queue](https://github.com/block/buzz/pull/8375#discussion_r4318421640),
[reconnect](https://github.com/block/buzz/pull/8375#discussion_r4318455512))
| Repaired at `66a12ac92`; evidence above. Reconnect refuses unproven
ownership rather than silently establishing a binding. |
| Incomplete Android identity inventory, stranded deep-link queue,
transient HTTP retry classification
([review](https://github.com/block/buzz/pull/8375#pullrequestreview-5576864143))
| All three repaired in `d3cc0003f`; focused journeys and isolated
mutation evidence above. |
| Account cross-link, incomplete recovery inventory, committed invite
checkpoint, pending local key retirement/replacement | Repaired in the
preceding commits; journey and mutation evidence above. |
| iOS interrupted key export
([review](https://github.com/block/buzz/pull/8375#pullrequestreview-5575253252))
| Native cleanup plus complete protection at creation implemented.
Physical-device lock and real interrupted sharing acceptance remains
pending. |
| Trickle responses bypass idle timeout; unrelated NIP-FA edit
([review](https://github.com/block/buzz/pull/8375#pullrequestreview-5575764556))
| Total request deadline/abort implemented; spec edit removed. |
| Production domains rejected | Ordinary entry now needs no Builderlab
configuration. Opt-in account-required domains must still use supported
community mappings. |
| Lost Android Keystore recovery | Backup exclusions prevent the
reviewed stale-restore scenario; genuine key loss still has no in-app
recovery/reset and remains deferred. |
| Native secp256k1 dependency question | Obsolete against the current
base: upstream C integration landed in #8398; no package diff remains
here. |
| Operator membership endpoint contract | Documentation now states the
operator chooses the pubkey; no end-user session binding is implied.
Additional handler-level coverage is optional follow-up. |
| Optional Android partial document cleanup/activity retention; local
work after backup cancellation | Follow-up hardening, not claimed
implemented. Cancellation prevents remote backup upload. |
| Semgrep crypto inventory notices | Informational inventory, not
demonstrated defects. |

## Remaining draft gates

Native QR/browser-return acceptance remains unverified for this
revision. Optional Builderlab onboarding still needs backend
backup-status and joined-community routes, recovery
policy/configuration, and real backup/recovery/reconnect acceptance. The
empty-domain build removes these services as prerequisites for ordinary
pairing; it does not make the optional hosted flows backend-ready. Human
acceptance and current CI are still required before merge.



Review dispositions retained: production auth-domain configuration must
use supported community mappings; enabling `builderlab.xyz` does not add
support for `buzz.block.builderlab.xyz`. Recovery after genuinely losing
an Android Keystore key remains a follow-up, not a newly implemented
capability.


Human acceptance steps: build once without `BUZZ_AUTH_DOMAINS`
(including with a previously saved account) and confirm no Builderlab
controls/restoration while ordinary link and QR/code entry work. Build
with supported domains and confirm connection/creation appear; exercise
browser return and live backup/recovery/reconnect. On physical iOS,
exercise export and interrupted sharing/locked-device protection. Record
results before marking review complete.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz>
Co-authored-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@wes.communities.public.builderlabstaging.xyz>
2026-10-19 15:46:58 -06:00

531 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
`GET /operator/communities/joined?member_pubkey=<64-char hex pubkey>` returns
active memberships for an operator-selected identity, excluding archived and
deleting communities. Like the provisioning routes, it requires the configured
operator origin and an allowlisted NIP-98 signature over the full request URL.
The relay authenticates the operator, not a Builderlab end-user session; the
operator is responsible for choosing the appropriate member pubkey.
#### 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).