Commit Graph

8 Commits

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

## What changes

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

## Tests

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

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

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

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-08 13:58:07 -04:00
Will Pfleger
032198ce43 feat(relay): add community reads to the admin API (#8011)
The in-app Admin Console needs to work on any community the relay
serves, not only the one the desktop is connected to. This adds four
read-only routes under `/api/admin/v1` that the stacked desktop PR
builds on, puts every admin route under one explicit access rule, and
binds every admin request's signature to its real method, which fixes
signed `HEAD` on reads.

## Access rule

| Mode | Moderation reads | Writes | Staffing (`/operators`) |
|---|---|---|---|
| `nip98` (default) | signed, any staff role | signed, role-checked |
signed Operator |
| `disabled` | served to anyone who can reach the relay | `403` | `403`
|

"Moderation reads" means every read: reports, feedback and attachments,
restrictions, and the four community routes below. `nip98` behavior is
unchanged, and the Operator API (`/operator/*`) is untouched.

**Existing `BUZZ_ADMIN_AUTH=disabled` deployments start serving the four
new reads** (community directory, member search, member lookup without
staff status, and stored events by ID) with no config change. The
startup `WARN`, `docs/admin/README.md`, `.env.example`,
`deploy/compose/.env.example` and the `just admin` comment list exactly
what disabled mode exposes, say it should be reachable only by people
allowed to see all moderation data in every community, and say the whole
relay port must be private: the admin API is on the relay's main
listener and its hostname check uses the caller's `Host` header. `just
admin` binds the relay to `127.0.0.1` unless `BUZZ_ADMIN_AUTH` is
exactly `nip98`, so an unset, padded or misspelled value stays local.

In code, `authorize()` returns `AdminAccess`, either `NetworkTrusted`
(disabled mode, no identity) or `Staff(AdminPrincipal)` (`nip98`, any
rostered Operator or Moderator). Each route makes one check: **view**
(`authorize()` succeeding), **act** (`AdminAccess::act()`, must be
`Staff`, then the existing role checks) or **operator**
(`AdminAccess::operator()`, must be an Operator). Act and operator
refuse `NetworkTrusted` with `403 this endpoint requires
BUZZ_ADMIN_AUTH=nip98`; no principal is ever created for an anonymous
caller. `every_route_makes_its_check_in_both_auth_modes` lists every
mounted admin route with its check and asserts the result in both modes,
and `every_route_is_listed` fails when `router()` mounts a route that
list does not name.

## Routes

All four live in `api/admin/reads.rs`; `mod.rs` only registers them.
Their queries reject unknown fields, like `ReportQuery`. The three
`?communityHost=` routes share `CommunityQuery`, which now rejects
unknown fields too, so the existing unban, untimeout and direct-action
routes that use it do the same; their desktop callers send only
`communityHost`. Every admin route that takes `limit` validates it with
the module's shared `limit()` helper, so an out-of-range value is `400
invalid_limit`.

| Route | Returns |
|---|---|
| `GET /communities?q=&cursor=&limit=` | `{items: [{id, host,
icon\|null}], nextCursor\|null}` |
| `GET /members/search?communityHost=&q=&limit=` | `{items: [{pubkey,
displayName\|null, nip05\|null, avatarUrl\|null}]}` |
| `GET /members/{pubkey}?communityHost=` | `{pubkey, profile:
{displayName, nip05, avatarUrl, about}\|null, role:
"owner"\|"admin"\|"member"\|null, banned, mutedUntil\|null, isStaff:
bool\|null}` |
| `GET /events/{id}?communityHost=` | `{id, authorPubkey, kind, content,
createdAt, deletedAt\|null, channelId\|null}` |

- **One community per read.** Community-scoped routes bind
`communityHost` through the tenant binder (`400 unknown_community_host`)
and read only that community's `users`, `relay_members`,
`community_bans` and `events` rows.
- **Directory.** It uses the binder's liveness rule (not archived, not
deleted, `deletion_state = 'active'`), so every listed host can be
opened. `q` is an escaped, case-insensitive host prefix of at most 255
characters (`400 invalid_query`). Results are ordered and paged by
`lower(host)`, which is unique, so the opaque base64url cursor carries
only the last host; a malformed cursor is `400 invalid_cursor`. `limit`
is 1 to 100 with a default of 50.
- **Member search.** It reuses `search_users`, which already escapes,
binds and scopes, with no second escaping layer. `q` is trimmed and must
be 1 to 100 characters (`400 invalid_query`), and `limit` is 1 to 50
(default 20). It searches community profiles, so former members appear.
- **Member lookup.** `isStaff` uses the same roster lookup as the
direct-action staff guard and is returned to signed staff only: it is
`null` in disabled mode, where the roster is never read. A roster lookup
failure is a 500, never "not staff".
- **Event preview.** It reads the event by `(community_id, id)` and
never returns a private kind (see below). A malformed id, an id stored
only in another community and a private kind are all `404
event_not_found`, the same answer as an absent event.

## Private events

Admin reads never reveal events the relay keeps private.
`admin_hidden_kinds()` combines the four private kind sets in
`buzz_core::kind`: `AUTHOR_ONLY_KINDS`, `RESULT_GATED_KINDS`,
`P_GATED_KINDS` and `SHARED_GATED_KINDS`. The whole shared-gated set is
hidden, including events tagged `["shared","true"]`; the shared tag is
never evaluated. Every HTTP response applies it the way it treats a
missing event: event preview is `404 event_not_found`, report detail
returns the report with `message: null` and `targetAuthorPubkey: null`,
and the report list returns a null target author.

Moderators can still act on these events, they just cannot read them.
Report detail is one query with the hidden-kind list as a parameter,
exposed two ways: `admin_get_report` hides private kinds and backs every
HTTP response (GET detail, the resolve response, reopen, cancel and the
reporter-notice worker), while `admin_get_report_for_enforcement` reads
the target unfiltered and is used only by the resolve handler's
enforcement load (the staff-target check and ban, timeout and kick
target derivation) and by the recovery worker for interrupted actions.
Its result is never serialized, so a ban from a report on a private
persona bans the real author while GET still shows nothing.

## Method binding

Axum serves `HEAD` through `GET` handlers, but the read handlers
authorized with a literal `"GET"`, so a correctly signed `HEAD` was
rejected. Admin reads now authorize through `authorize_read` and writes
through `authorize_write`, both of which use the request's real method
and full target, so no handler hardcodes a method string. A database
error behind any admin route is now logged before it becomes a 500.

## Directory query plan

`idx_communities_host` is on `lower(host)` in the default collation, so
the prefix `LIKE` is a filter, not an index condition. The plan walks
the index in order and stops once it has `limit` matches. A common
prefix is cheap (on local Postgres 16 with 21,359 communities, a first
page skipped 2,728 index rows in 3.9 ms), but a prefix with few or no
matches walks every index entry, so its cost grows with the number of
communities. A `COLLATE "C"` index would turn the prefix into an index
range; it is left out because the admin directory is low-traffic and the
cost is small at current scale.

The cursor condition is `lower(host) > COALESCE($2, '')`, with no `OR`,
so it remains an index condition under a generic plan:

```text
->  Bitmap Index Scan on idx_communities_host
      Index Cond: (lower((host)::text) > COALESCE($2, ''::text))
```

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-10-07 16:07:38 -04:00
Will Pfleger
8f8c4dfade feat(desktop): relay admin console for the /api/admin/v1 operator surface (#4768)
Adds a relay admin console under Settings — the single surface for
relay-level operator work — replacing the previously unreachable
moderation section. Operators authenticate with their own Nostr key over
NIP-98 — no browser extension, no bearer token — to view deployment-wide
moderation reports and product feedback, resolve/dismiss/escalate/reopen
reports, update feedback status, and manage the operator/moderator
staffing roster.

## Nav reachability

The old `moderation` section id was defined in `settingsSections` but
never wired into any group in `settingsNavGroups`, so the sidebar never
rendered it — unreachable since #1617. This PR replaces it with a
`relay-admin` section wired into the Communities nav group, pointing at
the relay admin console, and drops the separate `admin-console` section
id so a single surface owns relay-level trust & safety.

The Relay admin nav entry is always visible. Auth gates the panel
itself; the nav entry is a door, not a credential — hiding it behind
discovery state would strand an operator whose relay transiently fails
NIP-11 discovery with no path back to the connection settings.

## Discovery and connection

On mount the console reads the connected relay's NIP-11 document for the
optional `admin_api` field. When a valid origin is discovered **and its
host matches the connected relay's host** (case-insensitive, host
identity only), it is automatically saved and probed — the panel renders
immediately with no Save step required. A cross-host advertisement is
pre-fill only: it populates the manual origin field under Advanced and
is never saved or probed without an explicit Save, so a relay cannot
direct an unconsented NIP-98 signature at a third-party origin; residual
exposure on the same-host path is bounded by NIP-98's URL/method/payload
binding. Saving is only required for manual origin changes. The
advertised origin is untrusted input: revalidated through
`AdminOrigin::parse`, falling back to manual entry when absent or
invalid. A saved manual origin always takes precedence over discovery.

## Auth and transport (Rust, `src-tauri/src/commands/admin/`)

- `AdminOrigin` value object: validates scheme + host + optional port,
rejects credentials/path/query/fragment; `http://` only for loopback.
The relay side normalizes its configured admin host at config load and
compares inbound `Host`/`Origin` hosts case-insensitively.
- `AdminRoute` closed enum: no IPC surface accepts arbitrary URLs, so
the NIP-98-signed URL is byte-identical to the fetched URL.
- Dedicated no-redirect `reqwest` client as an SSRF guard: a relay `3xx`
is an error and the NIP-98 header never crosses origins.
- `admin_probe` signs `GET /probe` and returns a typed state
(`nip98Authorized` / `nip98Denied` / `disabled` / `notAdminApi` /
`networkOrIntercepted`). The response body is validated against the full
relay contract — `nip98Authorized` only when the complete NIP-98
invariant holds, `disabled` only on a coherent disabled-mode body; any
incoherent or unrelated JSON classifies as `notAdminApi`, so the console
never fails open at the network boundary. Unknown fields are tolerated
for forward compatibility.
- NIP-98 signing via `AppState::signing_keys()`, one retry on `401` with
a fresh signed event; response size bounded by a `Content-Length`
preflight and a streaming byte counter; per-pubkey origin storage with
atomic `0o600` writes.

## Relay admin console

The console is gated behind the relay `OPERATOR`/`MODERATOR` roles from
#3777; every `/api/admin/v1/*` route returns `403` to anyone else.
Community self-administration (`community-members` / Invites) and
in-channel enforcement (the 9040–9043 signed commands) are a separate
axis and are untouched. The previously-duplicate community report queue
(`ModerationQueueCard`, `moderationQueue.ts`, and three dead hooks) was
unreachable dead code and is deleted in its own commit;
`useBanMemberMutation` and the rest of `features/moderation/hooks.ts`
remain in use by the members sidebar and message menu.

## Console UI (TypeScript, `src/features/admin-console/`)

- `AdminConsoleSettingsCard`: discovery + probe flow, honest copy for
every probe state, manual origin under an `Advanced` disclosure at the
bottom. The role (operator/moderator) is rendered in the connection
status line as plain text ("Connected as operator") — no badge-shaped
elements above the tab row. The origin provenance (relay config /
database) appears inside the Advanced card as small muted text. Section
title changed to `Admin`.
- `AdminConsolePanel` with Reports / Feedback / Staffing tabs (Staffing
gated on `role === "operator"`); reports and feedback grouped by
community for cross-community triage.
- **Reports**: always requests `scope=all` so the existing
resolve/cancel/reopen controls can reach every status (`open`,
`processing`, `resolved`, `dismissed`, `escalated`). The relay's
omitted-scope default is `escalated`-only (the platform-safety
backstop); `scope=all` is explicit and scoped to this console. Full
action matrix per `target_kind` (event →
delete/kick/ban/timeout/dismiss/escalate; pubkey →
ban/timeout/dismiss/escalate; blob → dismiss/escalate); kick is
suppressed when the report carries no `channelId`. A `processing` row
stays navigable — enforcement state lives in the detail view. A `failed`
action (always pre-mutation) offers a single **Cancel & reopen** via
`POST /reports/{id}/cancel` with `{actionId}` fencing; there is no
client-side retry. `pending`/`enforcing` actions belong to the relay
recovery worker and offer no button; a `409` reloads detail. Lists
refetch on back-navigation after a mutation.
- **Report failure UX**: `reportErrorMessage()` strips 4xx/5xx status
prefixes and surfaces the relay's own error reason in the toast (e.g.
relay returns `"400: you cannot report this content"` → toast shows
`"you cannot report this content"`). Self-report blocking UI removed —
the relay has no self-report gate, and the failure was purely
client-side error surfacing.
- **Reason audience disclosure**: the Reason (optional) field shows
exact copy under the input based on selected action — Delete discloses
that reason is sent verbatim to the affected user and posted publicly in
the room; Kick/Ban/Timeout disclose affected-user only; Dismiss/Escalate
disclose reporter only. These mirror the actual relay notice paths.
- **Enforcement history**: the report detail carries the governing
action even after the report leaves `processing` — a report enforced
then reopened is `open` yet still shows its `succeeded` action as
executed history alongside the resolve form; a reopen does not un-happen
a ban. The resolve form gates on `open` status alone.
- **Reopen**: terminal reports (`resolved`/`dismissed`/`escalated`) can
be returned to `open` for re-triage via `POST /reports/{id}/reopen`.
Reopen never reverses applied enforcement, and the copy says so.
- **Feedback**: list, detail, and status control
(`new`/`reviewed`/`archived`) with a generation-fenced attachment
viewer. A purged source community severs provenance to `null` rather
than deleting the row; severed rows bucket under a "source community
removed" heading.
- **Staffing**: Add is create-only with display names resolved via
`useUsersBatchQuery` and npub cross-fade on hover
(`HoverStaffingIdentity`). An entered key that already appears in the
authoritative roster (config, owner-fallback, or DB) is rejected locally
with an inline duplicate-key message and no request is issued; a `409
Conflict` from add, role change, or remove surfaces the relay's parsed
error body via `adminErrorMessage(e)` — distinguishing config-backed key
conflicts from last-operator conflicts without HTTP-status inference.
Roster with `config`/`owner_fallback`/`db` source labels; config-backed
entries disable remove client-side. In-place role change via `<select>`
calling `putAdminOperator`; the relay responds with the effective
`OperatorEntry` (`pubkey`, `effectiveRole`, `sources`) in the same shape
as the roster list. Read-only badge for config-managed entries.
- **Timeout expiry**: `useTimeoutState` clears the store when derived
state is `INACTIVE` but the store is still active; the 1s tick is scoped
to entries with a known `expiresAtMs` and owned by
`ComposerTimeoutBanner` (mounted only while a timeout is active), while
`ChannelPane` subscribes to the boolean `useTimeoutActive()` — the pane
re-renders when a timeout starts or ends, not every second.
`useMembersSidebarModeration` ticks `nowMs` only while the sidebar is
open, the viewer can moderate, and some member has a future
`mutedUntil`; the tick after the last expiry stops the interval.
- Staffing refreshes the principal after a self-demotion or
self-removal: `AdminConsolePanel` passes `onSelfMutation` to
`StaffingTab`, which re-probes the saved origin so the role badge and
tab visibility update without a manual re-probe.
- Staffing mutation failures, including both 409 variants (config-backed
key vs. last operator), surface the relay's parsed error via
`adminErrorMessage`, so the last-operator recovery guidance is shown.
- Mutations confirm via `sonner` toasts; failures surface the relay's
parsed error `message`, not raw JSON. The `disabled` auth mode renders
read-only.

## Mutation idempotency

Resolve and reopen carry a per-attempt `requestId`. On first submit the
whole command (`{requestId, action, reason, expirationSecs}`) is frozen;
after an ambiguous failure (409, 5xx, transport error, truncated
response) the snapshot is retained, action/reason/duration controls are
locked, and the retry sends the exact same payload byte-for-byte. A
definitive pre-commit rejection (non-409 4xx with `bodyComplete: true`)
discards the snapshot, unlocks controls, and the corrected submit uses a
fresh payload. On success the toast derives from the authoritative
`AdminReportResolution` (`activeAction.action` when present; otherwise
`status` for decision-only outcomes) — never from the mutable form
selection.

The native mutation commands — including operator delete — reject with a
typed `AdminMutationError` carrying the relay's HTTP status
(`relayStatus`, `null` when no verdict exists) and a `bodyComplete` flag
that is `true` only when the full response body was read.

## Restriction routes contract change

`GET /members/restrictions` and `DELETE /members/{pubkey}/ban|timeout`
now take `communityHost` instead of `communityId`; the relay resolves
the tenant itself and returns `400 unknown_community_host` for an
unmapped host. This is a breaking contract change with no compatibility
shim: the relay must run this build before a desktop that uses it,
scripts sending `communityId` must switch, and rolling the relay back
breaks the desktop Restrictions UI. `docs/admin/README.md` documents the
three routes in the route inventory and carries the migration note.
Restriction calls also carry the relay the list was loaded from, and the
native side rejects a mismatch before building any URL.

## Hardening

- Saved admin origins are stored per pubkey and per relay host. The host
slug parses the relay URL, keeps the port-drop for DNS names, and
encodes IPv6 hosts canonically, so two IPv6 relays never share a file
and no IPv6 slug can match a DNS slug.
- NIP-11 admin discovery has its own 10s request deadline and reads
`/info` success and error bodies through the bounded reader with a 64
KiB cap (`Content-Length` is only a pre-check).
- `PUT /operators` builds its `OperatorEntry` response from the
validated body (`effectiveRole = role`, `sources = ["db"]`) instead of
re-reading the roster, so a concurrent change can't turn a committed
write into a `403`.

Related: #3777 (relay `OPERATOR`/`MODERATOR` role model + NIP-98 auth +
`admin_api` NIP-11 advertisement — provides the runtime and the
discovery field this console consumes)
Related:
[builderbot-platform-core-infrastructure#140](https://github.com/squareup/builderbot-platform-core-infrastructure/pull/140)
(prod admin ingress allowlist and NIP-98 rollout)


## Screenshots

Captured headless at `197f0ba0c` against the mock bridge (1280×720).

### Setup

**Origin setup — Advanced disclosure open, idle state**
![Relay admin panel with Advanced: admin origin disclosure open before
probe](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/02-origin-setup.png)

**Authorized console — all three tabs**
![Authorized relay console showing Connected as operator status and
Reports/Feedback/Staffing
tabs](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/03-origin-authorized.png)

### Reports

**Reports queue — open, processing, resolved across two relay groups**
![Reports tab showing community-grouped list with spam/open,
harassment/processing, illegal/resolved
rows](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/04-reports-queue.png)

**Open report detail — reporter, target, message snapshot, resolve
form**
![Open spam report detail with reported message content and
Delete/Kick/Ban/Timeout/Dismiss/Escalate action
buttons](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/05-report-detail-open.png)

**Processing report — PROCESSING/HARASSMENT, ban enforcing state**
![Processing harassment report detail showing enforcement state with ban
action in enforcing
status](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/06-report-enforcement.png)

**Resolve form — Delete selected, audience disclosure copy**
![Resolve form with Delete action selected showing "Sent verbatim to the
affected user and posted publicly in the room" audience
copy](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/07-resolve-form.png)

**Resolved report — enforcement history block**
![Resolved/illegal report detail showing Resolved by, Resolved at, and
Act ID fields from completed
enforcement](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/08-resolved-history.png)

### Feedback

**Feedback list — community grouping with status badges**
![Feedback tab showing relay.example.com group with new/reviewed items
and source-community-removed
group](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/09-feedback-list.png)

**Feedback detail — mutable status control (operator mode)**
![Feedback detail with new/reviewed/archived status tab control visible
for
mutation](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/10-feedback-mutable.png)

**Feedback detail — passive status badge (read-only/disabled mode)**
![Feedback detail in disabled mode showing passive "Status NEW" badge
with no mutation
control](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/11-feedback-readonly.png)

### Staffing

**Staffing roster — config/owner\_fallback/db source labels**
![Staffing tab showing three operators with config, owner_fallback, and
db source
labels](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/12-staffing-roster.png)

**Add form — new pubkey filled, ready to submit**
![Staffing add form with a new 64-hex pubkey entered and moderator role
selected](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/13-staffing-add.png)

**Duplicate rejection — inline error for existing principal**
![Staffing add form showing "Already an operator: operator. Use Remove
to revoke before re-adding with a different role." error for an existing
config-backed
operator](https://raw.githubusercontent.com/block/buzz/f92559f9fab694b51afd60fdf76cb276de2fa830/docs/pr4768/14-staffing-dup.png)

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Signed-off-by: Alia <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Alia <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@buzz.block.builderlab.xyz>
2026-09-24 17:34:25 -04:00
Will Pfleger
86b9142a09 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>
2026-08-28 11:43:05 -04:00
Jordan Mecom
769ac70b74 fix(media): require authenticated reads (#4610)
This change requires a valid signed Blossom authorization request and
current relay membership for every media GET and HEAD request. It
removes the unauthenticated compatibility path and updates desktop reads
to send the required authorization.

This blocks anonymous retrieval and access after relay-membership
revocation. It does not yet bind a blob to its originating channel, so
someone removed from a private channel can still read a known blob while
remaining a relay member. That channel-ACL follow-up remains required
before closing the full finding.

## Testing

- `git diff --check origin/main...codex/security-media-read-auth`
- Rebased onto `origin/main` at `5c98932`
- Full CI pending

Originating Buzz thread:
`buzz://message?channel=3928fe05-df61-4b5d-b9c7-d623b9b10ea1&id=3c6c02312f763fbe0d2bfc33a6c1a362f91d0354f3d18b039cf7a0558c1439d1`

---------

Signed-off-by: Jordan Mecom <jm@squareup.com>
Signed-off-by: Alex Rosenzweig <arosenzweig@squareup.com>
Signed-off-by: Eli Foster <efoster@squareup.com>
Co-authored-by: Eli Foster <efoster@squareup.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 00:46:42 +00:00
Will Pfleger
bcca885ba9 docs: replace VPN-vendor references with generic wording (#2805)
## Summary

- Replace all references to a specific corporate VPN product name with
generic "VPN" / "corporate VPN" / "VPN tunnel" / "VPN CLI" language
across 15 files (21 lines)
- Comment-only and doc-only changes — zero functional impact
- Keeps the OSS repo free of vendor-specific assumptions

`rg -i warp` returns zero hits after this change (excluding
`node_modules`, lockfiles).
2026-07-24 22:11:54 -04:00
Kalvin C
d7f918e3cb feat: proxy feedback-scoped admin attachments (#2059)
Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
2026-07-18 15:15:57 +00:00
Kalvin C
68e670e001 feat: add read-only deployment moderation dashboard (#1999)
Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1ntr8jjcqq6gt06q5avvqttfjgpwshmra22pcmcagdnukw4ja4nqqsa9g54 <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co>
2026-07-17 12:50:06 -07:00