mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
## 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>
531 lines
27 KiB
Markdown
531 lines
27 KiB
Markdown
# Deployment moderation dashboard
|
||
|
||
Buzz can expose a private, deployment-wide moderation dashboard from the existing
|
||
relay process. It shows open moderation reports and recent product feedback.
|
||
|
||
Configure `BUZZ_ADMIN_HOST` to activate the dashboard. A private ingress limits
|
||
access to the operator VPN or approved source IPs.
|
||
|
||
Required configuration:
|
||
|
||
```text
|
||
BUZZ_ADMIN_HOST=admin.example.com
|
||
BUZZ_ADMIN_WEB_DIR=/srv/buzz/admin-web
|
||
```
|
||
|
||
Plus one of the authentication modes below.
|
||
|
||
## Authentication
|
||
|
||
The admin API requires explicit authentication configuration. Setting only
|
||
`BUZZ_ADMIN_HOST` activates the dashboard in the default `nip98` mode. Configure
|
||
the mode with `BUZZ_ADMIN_AUTH`, which accepts `nip98` (default when unset) or
|
||
`disabled`.
|
||
|
||
`BUZZ_ADMIN_TOKEN` is no longer recognised: token authentication was removed.
|
||
Setting it is ignored with a startup warning, so a stale token variable cannot
|
||
silently run a deployment on a removed auth path — remove it from the environment
|
||
and use `nip98` (or `disabled` behind a network boundary).
|
||
|
||
### NIP-98 mode (`BUZZ_ADMIN_AUTH=nip98`, default)
|
||
|
||
Every `/api/admin/v1` request must carry a NIP-98 HTTP Auth header containing a
|
||
signed kind-27235 event. The signer's pubkey is resolved against a two-tier
|
||
principal model — **Operator** or **Moderator** — that grants capabilities
|
||
accordingly.
|
||
|
||
```text
|
||
BUZZ_ADMIN_AUTH=nip98
|
||
RELAY_OPERATOR_PUBKEYS=<64-char hex pubkey>[,<64-char hex pubkey>...]
|
||
```
|
||
|
||
- A malformed `RELAY_OWNER_PUBKEY` alongside `nip98` is a startup error (see
|
||
owner fallback below).
|
||
- `RELAY_OPERATOR_API_ORIGIN` is **not** required to run the admin console.
|
||
That origin is only used by the community-provisioning endpoints
|
||
(`POST /operator/communities`), which share the `RELAY_OPERATOR_PUBKEYS`
|
||
allowlist. When the pubkeys are set but the origin is not, the relay boots
|
||
with a `WARN` and provisioning requests fail closed at request time until the
|
||
origin is set — the admin console is unaffected.
|
||
|
||
`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).
|