feat(desktop): relay admin console for the /api/admin/v1 operator surface - #4768
wpfleger96 wants to merge 16 commits into
Conversation
42e67e3 to
9bb1666
Compare
…orcement states, feedback status, staffing tab Implement Plan v4 Phase 3 for the desktop admin console panel (#4768). ## Probe - AdminProbeResult::Nip98Authorized now carries optional role and source fields (Rust enum variant updated to struct variant). - AdminConsoleSettingsCard propagates role/source from probe result to AdminConsolePanel; AdminConsolePanel renders a role+source badge strip when role is present. ## Report actions (frozen v4 matrix) - Event reports: delete/kick/ban/timeout/dismiss/escalate - Pubkey reports: ban/timeout/dismiss/escalate - Blob reports: dismiss/escalate - ResolveReportForm generates a client UUID request_id per submission attempt (v4 §6a amendment 2); 409/processing errors preserve the request_id for retry idempotency. - Timeout action shows a duration (expiration_secs) input; submit is disabled until a value is provided. ## Enforcement states - processing reports are disabled (non-actionable) in the list with a spinner. - EnforcementStateBlock renders pending/enforcing/succeeded/failed states. - Failed actions surface Retry (reuses same request_id) and Cancel (dismiss with fresh request_id; server-rejected cancel treated as authoritative). ## Feedback status - FeedbackStatusControl: new/reviewed/archived PATCH buttons with optimistic local-state sync; server error surfaces inline. - FeedbackTab list shows non-new status as a badge. ## Staffing tab - Operator-only (gated by role === 'operator' from probe). - SourceBadge distinguishes config/owner_fallback (immutable) from db. - Config-backed operator rows have disabled remove buttons with title explaining why. - PUT 409 (config-backed add conflict) and DELETE 409 surfaced clearly. ## File structure AdminConsolePanel.tsx split into four files to satisfy the 1000-line ratchet (all new files under the limit): - AdminConsolePanelHelpers.tsx: AsyncState, useAsyncLoad, formatTimestamp, DetailRow, LoadingSpinner, ErrorMessage, AttachmentMeta, parseImetaAttachments - AdminConsoleFeedbackTab.tsx: FeedbackTab, FeedbackDetail, and related sub-components - AdminConsoleStaffingTab.tsx: StaffingTab, SourceBadge - AdminConsolePanel.tsx: ReportsTab, ReportDetail, report action components, TabBar, AdminConsolePanel root src-tauri/src/commands/admin/helpers.rs extracted from mod.rs to keep mod.rs under 1000 lines. ## Tests - 7 new tests: probe-role-source-badge, probe-moderator-role, probe-operator-role, probe-no-role, processing-report-not-actionable, action-matrix-types, plus reportButton.disabled assertion. - All 4511 TS tests pass; all 12 jsdom tests pass; Rust compiles clean; desktop-check, desktop-tauri-check, desktop-tauri-test all green. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
fa29ae1 to
99527b1
Compare
99527b1 to
6bd27a2
Compare
6bd27a2 to
fbb8b6c
Compare
…y-scoped nav gate Close the desktop half of Thufir's #4768 pass-1 findings that need no relay change. The relay-contract consumption (canonical action DTO, real cancel route) waits on #3777. Processing report rows were disabled in the list, but the enforcement progress/retry/cancel UI lives only inside the detail view — so the row was locked exactly when an operator needs to inspect a pending or failed action. Keep processing rows navigable; the detail view already suppresses the resolve form for any non-open report. Feedback triage `status` was optional on the wire types and silently defaulted to "new" when absent, misreporting a reviewed/archived entry as new after reload. Make `status` required on both feedback DTOs and read it directly, and type PATCH's actual `{status}` echo instead of claiming a full summary record. The Moderation nav resolver keyed its 60s cache on pubkey alone, but NIP-11 discovery is relay-dependent — a workspace switch could serve the previous relay's verdict. Key the resolver on the connected relay origin (and gate its `enabled` on a resolved origin), and defer the `?section=moderation` invalid-section redirect while the resolver is unresolved so a direct link is not bounced before the probe can authorize. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes requested
-
[P1] Do not auto-probe an unrestricted relay-advertised origin —
desktop/src-tauri/src/commands/admin/mod.rs:938-949admin_apiis explicitly untrusted NIP-11 data, but discovery applies the same permissive policy as manual operator input: HTTP loopback is accepted and every HTTPS host is accepted. Both the nav resolver and settings card then probe that value automatically without confirmation (hooks.ts:45-56,AdminConsoleSettingsCard.tsx:303-316). A malicious connected relay can therefore make the native client contacthttp://localhost:<port>(the client pinslocalhostto127.0.0.1) or HTTPS private/link-local targets. If the target returns401 WWW-Authenticate: Nostr, the app sends it a fresh app-key signature, adding a signing oracle and pubkey-ownership disclosure to the SSRF. Separate advertised-origin validation from manual configuration: do not auto-probe loopback/private/link-local advertised destinations, and account for DNS resolution/rebinding, or require explicit operator confirmation before any cross-origin probe. Keep the loopback HTTP carve-out only for manual origins. -
[P2] Actually make disabled-auth mode read-only —
desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx:397-480disabledcurrently renders the sameAdminConsolePanelas an authorized principal, with only the staffing tab hidden. The report resolve/reopen/cancel controls and feedback status controls remain enabled, although the related relay contract intentionally rejects every mutation in disabled mode with 403. This contradicts the PR’s stated read-only behavior and presents controls guaranteed to fail. Pass a read-only capability into the panel and suppress/disable all mutation affordances when the probe result isdisabled.
The command registration/API seams, identity and async fencing, report lifecycle/idempotency paths, attachment cleanup, navigation, staffing gating, and legacy-surface removal were otherwise coherent in this read-only diff review. GitHub currently exposes only a passing DCO check for this head; PR code was not executed locally under the automation trust policy.
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Two security/resource-boundary blockers remain on this head:
-
Do not auto-probe a relay-advertised admin origin under the manual-origin trust policy. The NIP-11
admin_apivalue is explicitly untrusted, butadmin_origin_from_nip11passes it through the sameAdminOrigin::parsepolicy used for operator-entered values. That policy accepts loopback HTTP and unrestricted HTTPS hosts. Both the navigation resolver and the settings card then automatically probe the advertised origin, and the probe performs a native GET before sending a fresh NIP-98 signature when the target replies with401 WWW-Authenticate: Nostr. A malicious connected relay can therefore advertisehttp://127.0.0.1:<port>/localhost(the client explicitly pinslocalhostto127.0.0.1) or an HTTPS private/link-local target and trigger zero-click native SSRF; a cooperating target also gets an attacker-triggered signing oracle and proof of the app pubkey. This is not hypothetical policy drift:mod_tests.rscurrently asserts that a loopback origin from NIP-11 is accepted and returned for automatic probing. Please separate advertised-origin validation from explicit manual configuration: never auto-probe loopback/private/link-local advertised destinations, and account for DNS rebinding, or require an explicit operator confirmation before any cross-origin probe. Keep loopback HTTP only for a manually entered origin. -
Bound attachment preview work across the whole feedback item, not only per response.
parseImetaAttachmentsaccepts and returns every validimetatag,FeedbackDetailmounts anAttachmentViewerfor every result, and eachimage/*viewer immediately starts its own native fetch. The Rust command caps each response at 10 MiB, but there is no attachment-count, aggregate-byte, or concurrency bound. The relay’s feedback ingest bounds serialized tags to 64 KiB but does not cap the number ofimetaentries, so an admitted feedback event can fan out many simultaneous image reads and retain their blob URLs until navigation. The existing test explicitly blesses multiple tags but covers no aggregate/concurrency ceiling. Please impose a small count plus aggregate-byte budget before rendering/fetching, avoid unbounded parallel auto-load, and add a regression showing excess attachments are not requested.
The closed route construction, exact-body NIP-98 binding, no-redirect client, mutation idempotency/recovery fences, and identity/origin React state fences otherwise held in this read-only diff review. GitHub currently exposes only the passing DCO check for this head; I did not execute PR code under the automation policy.
Accidental duplicate from a concurrent automated recovery. The earlier marked review 5032499207 is authoritative.
…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: [#4768](#4768) (desktop admin console consuming the `admin_api` field), [squareup/bb-public#339](squareup/bb-public#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>
…y-scoped nav gate Close the desktop half of Thufir's #4768 pass-1 findings that need no relay change. The relay-contract consumption (canonical action DTO, real cancel route) waits on #3777. Processing report rows were disabled in the list, but the enforcement progress/retry/cancel UI lives only inside the detail view — so the row was locked exactly when an operator needs to inspect a pending or failed action. Keep processing rows navigable; the detail view already suppresses the resolve form for any non-open report. Feedback triage `status` was optional on the wire types and silently defaulted to "new" when absent, misreporting a reviewed/archived entry as new after reload. Make `status` required on both feedback DTOs and read it directly, and type PATCH's actual `{status}` echo instead of claiming a full summary record. The Moderation nav resolver keyed its 60s cache on pubkey alone, but NIP-11 discovery is relay-dependent — a workspace switch could serve the previous relay's verdict. Key the resolver on the connected relay origin (and gate its `enabled` on a resolved origin), and defer the `?section=moderation` invalid-section redirect while the resolver is unresolved so a direct link is not bounced before the probe can authorize. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
ff769d3 to
587d23d
Compare
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes requested
-
[P1] Do not automatically probe an unrestricted relay-advertised origin —
desktop/src-tauri/src/commands/admin/mod.rs:938-950admin_apiis untrusted NIP-11 data, but discovery runs it through the manual-entry policy, which accepts loopback HTTP and every HTTPS host (origin.rs:64-90). With no saved origin, both the settings surface and navigation resolver automatically probe the advertised value (AdminConsoleSettingsCard.tsx:284-316,hooks.ts:41-56). The native probe first GETs that attacker-selected destination and, if it answers401 WWW-Authenticate: Nostr, sends a fresh NIP-98 signature to it (mod.rs:160-215). A malicious or compromised connected relay can therefore target localhost, private/link-local addresses, or an attacker hostname that resolves there; no-redirect handling does not constrain this initial destination. Separate advertised-origin trust from explicit manual configuration: require an explicit confirmation or prove/bind the advertised endpoint to the connected relay, and reject resolved loopback/private/link-local/reserved targets with rebinding-safe connection handling. Add advertised-origin tests for loopback, RFC1918/link-local, and a public hostname resolving privately. The existing discovery test currently blesses automatic loopback probing (mod_tests.rs:872-916). -
[P1] Bound attachment fan-out across the feedback item —
desktop/src/features/admin-console/AdminConsolePanelHelpers.tsx:243-272parseImetaAttachmentsreturns every validimetatag,FeedbackDetailmounts every result (AdminConsoleFeedbackTab.tsx:420-465), and eachimage/*viewer immediately starts its own native fetch on mount (AdminConsoleFeedbackTab.tsx:215-224). The 10 MiB native cap is per response, with no attachment-count, aggregate-byte, or concurrency ceiling. A single admitted feedback event can therefore trigger many simultaneous native reads and retain all resulting blobs until navigation. Impose a small item-wide count/aggregate budget and bounded fetch concurrency (or explicit loading), then add a regression proving excess attachments are not requested. -
[P2] Keep disabled-auth mode read-only as documented —
desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx:470-480A coherent
disabledprobe mounts the same panel as an authorized principal without passing a read-only capability. Report resolve/reopen/cancel and feedback status controls therefore remain live, although the relay rejects mutations in this mode. This contradicts the documented contract that disabled mode renders feedback status read-only (docs/admin/README.md:267-276) and the PR description. Pass the auth capability into the panel and hide or disable every mutation affordance while preserving reads. -
[P2] Expose the selected feedback status programmatically —
desktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx:352-375The current status is communicated only through button styling (
variantand ring classes). The three ordinary buttons have noaria-pressed, radio semantics, or textual current-value output, so a screen-reader user cannot determine the active status before changing it. Add a programmatic selected state and assert that semantic contract rather than only the visual class.
The closed route construction, exact URL/body NIP-98 binding, no-redirect client, response caps, identity/origin async fences, report lifecycle and request-ID recovery contract, role-gated staffing, and legacy moderation-surface removal were otherwise coherent in this read-only exact-head review. Desktop, Rust, build, and desktop integration checks are green; PR code was not executed locally under the automation trust policy.
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes requested at exact head 63db828a09b270239d5e56bf82c3f5e8468cea43 and exact base a3730784fc851bb1125b40cca9b0a30788a293c1.
P1: require confirmation before removing an operator
The Staffing trash button calls handleRemove directly, which immediately invokes deleteAdminOperator (desktop/src/features/admin-console/AdminConsoleStaffingTab.tsx:105-121,203-224). One stray click therefore deletes a DB-backed operator, potentially the acting operator, without a confirmation, target/role recap, or undo. That can remove administrative access and may require another operator or out-of-band configuration to recover.
Put this destructive authorization change behind an explicit confirmation dialog. Make self-removal especially clear, and add cancel/confirm behavior tests. The current event suite only checks whether staffing controls are rendered; it does not exercise removal confirmation.
P2: recover when the active Staffing tab becomes unauthorized
activeTab persists independently of role (AdminConsolePanel.tsx:832-843). If the same mounted panel changes from operator to moderator/unknown while Staffing is selected, the tab button disappears and the content is suppressed (AdminConsolePanel.tsx:859-887), leaving an empty panel with no selected visible tab. Reset an invalid active tab to Reports when role/capabilities change and cover that prop transition.
The previous advertised-origin SSRF, attachment fan-out, disabled-mode mutation, and feedback-status accessibility blockers are otherwise closed at this head. Relay discovery is now prefill-only until an operator explicitly saves the origin; item-wide attachment limits, read-only mutation gating, and aria-pressed are present. Hosted substantive checks are green. This was a read-only review of immutable GitHub source/diffs; no PR code was checked out or executed.
Refreshed all 14 screenshots with the new Relay admin nav label and ServerCog icon. Shot 01 renamed from 01-moderation-nav to 01-relay-admin-nav to match the updated surface name. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Review clear: prior blockers resolved
Reviewed head 21f34f0138a4e00782f914d28a90346b994b650c against base 42aeb1571cc1f1ed4d73f05b420474be970021ab, including the corrections since review 5073088157. No remaining blocking finding in the reviewed paths and agreed delivery contract.
- Staffing: Add now requires a loaded roster and rejects normalized existing keys before PUT. The actual form/IPC regression checks zero PUTs for an existing operator and a successful new-moderator add with roster refresh. This closes the demonstrated accidental-demotion path; the API remains an upsert, so this is snapshot-based duplicate prevention, not atomic server-side create-only behavior.
- Manual localhost:
http://admin.localhost:<port>is accepted and pinned to loopback without rewriting the hostname used for Host or NIP-98 signing. The added production-client/probe test inspects the signing URL and both wire Host headers. Discovery still requires explicit Save before contacting an advertised admin origin. - Integration: Read-only feedback detail now shows its status. The “Relay admin” rename preserves the old
moderationURL token in route validation and AppShell. Report scope, frozen resolve retries, cancel/reopen semantics, and operator/moderator authorization remain consistent with the previously reviewed contract.
Non-blocking follow-ups: the staffing regression does not explicitly exercise uppercase/whitespace, self-key, or pending/failed roster loading. Discovery’s reserved-domain check omits .localhost, so failed system DNS can allow a loopback prefill; explicit Save still prevents automatic access. These do not reopen the agreed fixes.
Validation: immutable source/diff and existing-test inspection only, with independent native and staffing/feedback reviews integrated. No checkout, builds, tests, or PR-code execution; no runtime-pass claim. Separate community moderation, mobile/web operator UX, deployment configuration, and unrelated merged-main changes were outside this corrective review. This is a COMMENTED review, not approval.
…face Add a NIP-98 client for the /api/admin/v1 relay API, surfaced as a "Relay admin" section in Settings. Relay operators view deployment-wide moderation reports and product feedback, resolve/dismiss reports, update feedback status, and manage the operator/moderator staffing roster — no browser extension or bearer token required. The console auto-discovers the relay's admin origin from its NIP-11 document (admin_api field): on mount it fetches the connected relay's relay-information document, validates the advertised origin through AdminOrigin::parse, and auto-probes. The manual origin field remains as a pre-filled fallback for relays that do not advertise. Rust: - AdminOrigin value object: validates scheme+host+optional-port, rejects credentials/path/query/fragment; http:// only for loopback hosts - AdminRoute closed enum: no IPC surface accepts arbitrary URLs or paths; the signed URL is byte-identical to the fetched URL - Dedicated no-redirect reqwest client (SSRF guard: relay 3xx surfaced as error, NIP-98 header never forwarded across origins) - admin_probe: typed state enum (Nip98Authorized/Denied, TokenMode, Disabled, NotAdminApi, NetworkOrIntercepted); Nip98Authorized only on authenticated 2xx - NIP-11 admin-origin discovery command; advertised value treated as untrusted input and revalidated before use - NIP-98 signing via AppState::signing_keys(); one retry on 401 with a fresh event - Response bounds enforced by Content-Length preflight and streaming byte counter; per-pubkey origin storage (atomic write, 0o600) TypeScript: - admin-console API wrappers for all Tauri commands; attachments return a Blob URL from caller-supplied MIME - AdminConsoleSettingsCard: auto-discovery + probe flow, per-pubkey state, honest copy for every probe state - AdminConsolePanel with Reports / Feedback / Staffing tabs - Settings panels split under the file-size ratchet - Legacy ?section=moderation URL token aliased to relay-admin - Deleted the unreachable community moderation queue card Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
21f34f0 to
efd15bb
Compare
Items addressed:
1. Render without Save: auto-discover → auto-save → auto-probe on first
open. Panel renders immediately; Save is only needed for manual
origin changes. Falls back to pre-fill if set_admin_origin rejects.
2. Remove auto-detect tooltip sentence from SettingsSectionHeader
description. Rename section title 'Relay admin' → 'Admin'.
3. Layout reorder: probe status badge + AdminConsolePanel moved ABOVE
the Advanced <details> disclosure. Origin input goes to the bottom.
4. Badge cleanup: the role (OPERATOR) and provenance (CONFIG/DB) pills
above the tab row were confusing users into thinking they were nav.
Remove both from above the tabs. Fold the role into the connection
status line as plain text ('Connected as operator'). Move provenance
into the Advanced card as small muted text ('Origin resolved from
relay config'). Zero badge-shaped elements remain above the tabs.
5. Nav rename: SettingsPanels.tsx label 'Relay admin' -> 'Admin'.
Updated matching comments in nav.ts and hooks.ts.
6. Staffing parity with Invites: display names via useUsersBatchQuery,
npub cross-fade on hover (HoverStaffingIdentity), in-place role
change via <select> calling putAdminOperator, read-only badge for
config-managed entries, error copy updated to mention edit control.
mountPanel test harness now wraps AdminConsolePanel in a
QueryClientProvider so useUsersBatchQuery resolves cleanly.
7. Timeout expiry rendering: useEffect in useTimeoutState clears store
when derived state is INACTIVE but store still active; tick interval
now starts for null-expiry entries so the blocking overlay does not
persist after TTL. useMembersSidebarModeration gets a 1s nowMs
interval so moderationStateByPubkey re-runs reactively on expiry.
8. Report failure UX: reportErrorMessage() strips HTTP status prefixes
and surfaces the relay's own error reason in the toast. Self-report
blocking UI removed per Hayt's verdict (relay has no self-report
check; failure was client-side error surfacing only).
Tests: updated discovery-success test; added discovery-save-fails-falls-
back test; new timeoutStore.test.mjs; new reportMessageDialog.test.mjs.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…pe timeout ticker Pass-2 fixes for Thufir review findings: IMPORTANT: replace bespoke useQuery/getUsersBatch in StaffingTab with the shared useUsersBatchQuery hook. CommunitiesProvider wraps the entire Settings tree (main.tsx), so the hook is always in context; the prior false comment is removed. This picks up normalized delta-fetch, per-pubkey caching, persisted-label seeding, focus-recovery retry, and profile-cache seeding that the bespoke path bypassed. Also removes the now-unnecessary check-pubkey-truncation allowlist entry for the deleted .slice().sort() queryKey. MINOR: scope the useTimeoutState interval to known-expiry timeouts (expiresAtMs !== null). The null-expiry case ticked every second without state-change potential (isTimeoutActive(null) is permanently true) — a pure no-op re-render loop. Fix the doc comment to match; known-TTL expiry path is unchanged. Tests: add CommunitiesProvider to the mountPanel and staffing-tab-reset-on-role-downgrade harnesses so useUsersBatchQuery resolves in jsdom. Four new behavioral tests: - staffing-display-name: resolved profile name renders (not raw pubkey) - staffing-npub-hover: npub span present and encodes to npub1... - staffing-role-change-success: PUT fired with correct pubkey+role, list refreshes - staffing-role-change-409: 409 rejection surfaces config-backed copy, not raw error Biome gate: remove unused container destructure (adminConsolePanel.test.mjs:1391), string concat → template literals (inboxReopenNavigation.test.mjs), computed keys (channelMutesStorage, channelStarsStorage). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2697185 to
b0b5d64
Compare
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes requested: two regressions since the previous clear review
Reviewed head b0b5d6467ea6f87c58f86f62b796a13a319e761c against exact base 88687876f7808a2fd742b7eb2e4b9f87d999ad8d, with a source-level delta from previously cleared head 21f34f0138a4e00782f914d28a90346b994b650c.
P1: Do not automatically trust and probe the advertised admin origin
desktop/src/features/admin-console/AdminConsoleSettingsCard.tsx:322–335 now persists discovery into the manual-origin store and calls runProbe without confirmation. The unchanged native discovery implementation was safe only as a prefill: discovery.rs:50–55 rejects bare localhost but not .localhost, and lines 75–79 accept DNS lookup errors expressly because the operator would still have to Save.
Concrete path: on a platform where system DNS does not resolve .localhost, let the connected relay advertise http://admin.localhost:<port> and open Admin with no saved origin. Discovery accepts the lookup failure; the new effect saves the origin; the dedicated admin client then deliberately resolves .localhost to 127.0.0.1 (dns.rs:34–41). The client reaches that loopback service without the user choosing it. If it answers 401 with WWW-Authenticate: Nostr, mod.rs:199–217 signs and sends a NIP-98 probe with the active app key. The route/method are fixed; this is unauthorized local-network access and a signed identity disclosure, not private-key extraction. Disabling redirects does not protect the initial destination.
This was explicitly non-blocking in the prior review only because explicit Save prevented all contact. Removing Save makes it reachable. The new jsdom success test (adminConsolePanelEvents.jsdom-test.mjs:1677–1750) mocks discovery returning literal loopback and bypasses the native checks, so it cannot establish the advertised-origin security contract.
Smallest exit: restore discovery as prefill-only until explicit Save, preserving manually trusted internal origins. If zero-confirmation discovery is intentional, it needs a separately secured advertised-origin transport policy, including .localhost/DNS failure and connection-time address binding; the manual syntax parser alone is not sufficient. Cover the real discovery-to-probe boundary, not just the IPC success mock.
P2: Reconcile the active principal after an in-place self-demotion
The new role selector calls handleRoleChange (desktop/src/features/admin-console/AdminConsoleStaffingTab.tsx:383–400), whose successful PUT only increments the local roster generation (209–211). With DB operators A and B, sign in as A and change A’s row to moderator. The mutation commits because B remains an operator (crates/buzz-db/src/store/relay_operators.rs:168–179). The subsequent roster GET correctly rejects A as a moderator (crates/buzz-relay/src/api/admin/auth.rs:285–307,325–333; admin/mod.rs:913–924).
But the console still says Connected as operator, retains Staffing as the selected/visible tab, and replaces the roster with an error. The parent SettingsCard owns the probe and is never updated (AdminConsoleSettingsCard.tsx:363–383,434–442); Panel’s existing operator-to-moderator reset cannot fire because its role prop has not changed (AdminConsolePanel.tsx:929–943). This is incorrect post-mutation state, not an authorization bypass. Re-probing/remounting repairs presentation; only another operator can restore A’s staffing authority.
Exit: after successfully changing the current principal’s role, refresh the parent authorization state and move to an allowed tab rather than only reloading the operator-only roster. Exercise the real SettingsCard mutation-to-probe path with A/B, assert moderator status and Reports/Feedback without Staffing. The existing test manually supplying a changed role prop proves only the downstream reset; the new test covers another user’s promotion (adminConsolePanelEvents.jsdom-test.mjs:6101–6184). Consider the same explicit self-access warning already used by Remove, but the stale principal transition is the concrete blocker. Add duplicate rejection and server last-operator protection remain intact.
Coverage and limits
The relay remains authoritative for authorization, enforcement, provenance and audit, separately from community owner/admin moderation. Reviewed Settings/navigation/identity/origin transitions; native discovery/signing/transport; report list/detail/resolve/cancel/reopen and retry contracts; feedback lifecycle; staffing; timeout/sidebar/report-error deltas; and command registration. Checked duplicated report/action/target/feedback/role/source/probe/settings closed sets. Report lifecycle, feedback, native admin transport and auth blobs remain identical to the prior clear where noted; the Panel change is presentation-only. Prior report scope, frozen resolve retries, manual .localhost support, read-only feedback status, and Add duplicate fixes remain intact.
Non-blocking coverage follow-up: the added timeout tests exercise imperative store/pure helpers, not the changed reactive hooks. Hook-level fake-timer tests would make expiry and interval cleanup regressions falsifiable. No concrete new runtime failure was established there. Existing raw staffing error presentation and snapshot-based Add concurrency are not additional merge gates.
Validation was immutable source/diff and existing-test inspection only, with independent review lanes integrated. No PR checkout, build, test execution, or live app/relay run. Separate community moderation as a whole, mobile/web operator interfaces, deployment configuration, and unrelated rebase changes were excluded.
The report dialog stripped any leading NNN: from error messages, truncating legitimate reasons that start with digits and a colon. Only strip 4xx/5xx prefixes. Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…e client build error AdminOrigin now stores the parsed host and port from parse() instead of re-parsing the canonical string in resolution_target() behind expect()s, and init_admin_client() propagates the reqwest builder error through the Tauri setup closure instead of panicking. On build failure no client is stored, so commands still hit the not-initialised guard rather than any redirect-following fallback. Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
shouldShowRelayAdminNav and useModerationNavResolution had zero production consumers — SettingsView deliberately shows the relay-admin entry unconditionally and auth gates the panel itself. The module's tests asserted the abandoned hide-on-none contract, misleading maintainers about shipped behavior. Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The 1 Hz nowMs interval ran whenever the sidebar was open, reconciling every member card each second for any viewer even with zero timeouts. Tick only while the viewer can moderate and some member has a future mutedUntil; the tick after the last expiry flips the gate off and the cleanup clears the interval. Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
ChannelPane subscribed to the full ticking timeout snapshot, re-rendering the whole pane (un-memoized message list) at 1 Hz during an active timeout when it only needs the once-flipping active boolean. The pane now reads useTimeoutActive() and ComposerTimeoutBanner owns the ticking subscription, scoping the tick and clear-on-expiry effect to its mount. Adds a jsdom test binding the clear-on-expiry effect. Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Feedback and Staffing tabs already have their own files; the Reports components stayed inline, pushing AdminConsolePanel.tsx toward the 1200-line desktop file-size gate. Pure extraction — only ReportsTab is exported; the panel keeps the tab bar and the attachment re-exports. Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…rouping resolveAdminReport can also fail with 422 enforcement_failed; record its requestId classification (authoritative 422 resets, truncated preserves) alongside 401/403/409. Also memoize groupByCommunity in CommunityGroupedList. Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…{pubkey}
The handler returned a bare {pubkey, role} while the desktop types the
response as AdminOperatorDto {pubkey, effectiveRole, sources[]}, leaving
those fields silently undefined. Re-resolve the principal after the
upsert and return the same OperatorEntry shape the roster list uses.
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The add, role-change, and remove handlers string-matched "409" in e.message and rendered the raw message, missing native-layer transport errors that carry relayStatus without the literal substring. Classify via adminMutationRelayStatus(e) and render adminErrorMessage(e); tests now reject with typed errors whose message contains no "409" so the string-matching path stays falsifiable. Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The configured admin host is already lowercased at config load, but is_admin_host compared the inbound Host verbatim, 403ing mixed-case hosts from proxies and non-desktop clients. Compare Host and the host portion of Origin case-insensitively while keeping the exact-scheme plaintext-origin guard. Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
admin_delete_operator returned a String error, so delete failures lost relayStatus/bodyComplete and the UI could not classify config-backed 409s. Route DELETE through the shared typed mutation path: an optional body on mutation_admin_json (None signs NIP-98 over the empty payload with no wire body or Content-Type, byte-identical to the old bare DELETE) replaces the bespoke DELETE skeleton. Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Discovery auto-saved and auto-probed whatever admin_api origin the relay advertised, so a malicious relay pointing at a cross-host origin could collect an unconsented NIP-98 signature the moment that origin answered 401. admin_discover_origin now reports whether the advertised host matches the connected relay's host (case-insensitive, host-only); only same-host origins keep the auto flow, while cross-host advertisements just pre-fill the manual field for an explicit save. Discovery docs now describe the shipped auto flow and this trust binding instead of the abandoned pre-fill-only design. Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…n-surface * origin/main: (23 commits) chore(release): release Buzz Desktop version 0.5.23 (#7381) fix(desktop): keep packaged frontendDist relative so Windows embeds assets (#7177) fix(sidebar): simplify unread indicators and emphasize priority activity (#7134) Add generic information-flow control core (#7293) feat(buzz-acp): update base prompt; add buzz context and skills to Pi agents (#7335) fix(desktop): restore mention chip identity icons (#7338) Persist video playback speed preference (#7336) Verify ACP relay events before prompt routing (#7010) fix(buzz-acp): bound busy-owner hold to prevent cross-channel starvation (#7337) feat(desktop): invite owned agents from standalone forums (#7125) fix(desktop): authorize remote mentions at publication (#7124) fix(acp): rename system tag to agent-instructions (#7332) fix(desktop): bind duplicate mention selections to exact recipients (#7133) refactor(relay): extract NIP-29 membership authorization (#7285) chore(release): release Buzz Desktop version 0.5.22 (#7308) feat(desktop): preserve mentions across copy and paste (#7228) test(desktop): await Bestie drag and profile hover endpoints (#7294) Collapse contiguous join messages (#7262) chore(release): release Buzz Desktop version 0.5.21 (#7301) fix(scripts): copy global-agent-config.json in buzz-adopt-prod-agents (#7303) ... Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes requested: two P2 Staffing defects
Reviewed head efa68d25829d357b7b557cbe92eb2e48cd094be0 against base 3c7f288c60d67df78577b237e27c3dfc8831aaa1, focusing on changes since reviewed head b0b5d6467ea6f87c58f86f62b796a13a319e761c and the agreed integration contracts. Relay operator/moderator authority remains separate from community administration; the relay is authoritative for permissions and mutation outcomes.
P2: Refresh the active principal after successful self-demotion (unresolved)
Staffing’s successful role change only increments its roster generation. With two effective DB operators A and B, sign in as A and change A to moderator. The PUT succeeds, then the operator-only roster reload returns 403, but Settings still says Connected as operator and retains Staffing. The parent’s role comes exclusively from its previous probe; no mutation callback refreshes it. This is stale authorization presentation, not a server authorization bypass. Successful self-removal has the same reconciliation gap.
Exit: after changing/removing the current principal, refresh the parent probe and reconcile visible tabs/access. Exercise the actual Settings → Staffing mutation → principal transition, asserting moderator status and Reports/Feedback without Staffing (or denied state after removal). The existing role-downgrade test manually supplies a new role prop, so it cannot catch the missing upstream update.
P2: Preserve the last-operator conflict’s recovery instruction (new regression)
The new role-change 409 branch and remove branch classify every typed 409 as a config-backed key. With no effective config operator and one DB operator, attempt to demote or remove that operator. The relay correctly rejects it with “add a replacement operator first” (PUT, DELETE). The UI instead claims this mutable DB row is config-backed, hiding the supported recovery and directing the operator toward the wrong cause.
Exit: display the relay’s parsed error message for these conflicts, or distinguish a stable conflict reason rather than treating HTTP status as one. No broader transport redesign is needed. Cover both immutable-config and last-operator responses for role change/removal; the new 409 tests currently encode only the immutable interpretation.
Fixes, boundaries, and validation
Cross-host discovery now requires explicit Save, closing the prior automatic cross-host/.localhost signing path. Intentional same-host discovery, including another port, is not a finding. The discovery DNS check is not connection-time address binding, but the proposed additional proof-disclosure scenario requires a TLS endpoint valid for the already-trusted hostname; no additional credential capability was established. Treat that as non-blocking hardening, preserving manually trusted internal origins.
The report extraction preserves the prior implementation and frozen retry/action contracts. Inspected timeout/banner/sidebar lifecycle changes and new reactive tests; no additional defect established. Native bodyless DELETE retains its wire/signing contract. Independent lanes were integrated. Validation was exact-source/diff and test-source inspection only: no PR checkout, build, test execution, or live workflow. Unrelated rebase changes, separate community moderation as a whole, and mobile/web operator interfaces were excluded.
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
moderationsection id was defined insettingsSectionsbut never wired into any group insettingsNavGroups, so the sidebar never rendered it — unreachable since #1617. This PR replaces it with arelay-adminsection wired into the Communities nav group, pointing at the relay admin console, and drops the separateadmin-consolesection 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_apifield. 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 throughAdminOrigin::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/)AdminOriginvalue 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 inboundHost/Originhosts case-insensitively.AdminRouteclosed enum: no IPC surface accepts arbitrary URLs, so the NIP-98-signed URL is byte-identical to the fetched URL.reqwestclient as an SSRF guard: a relay3xxis an error and the NIP-98 header never crosses origins.admin_probesignsGET /probeand returns a typed state (nip98Authorized/nip98Denied/tokenMode/disabled/notAdminApi/networkOrIntercepted). The response body is validated against the full relay contract —nip98Authorizedonly when the complete NIP-98 invariant holds,disabledonly on a coherent disabled-mode body; any incoherent or unrelated JSON classifies asnotAdminApi, so the console never fails open at the network boundary. Unknown fields are tolerated for forward compatibility.AppState::signing_keys(), one retry on401with a fresh signed event; response size bounded by aContent-Lengthpreflight and a streaming byte counter; per-pubkey origin storage with atomic0o600writes.Relay admin console
The console is gated behind the relay
OPERATOR/MODERATORroles from #3777; every/api/admin/v1/*route returns403to 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;useBanMemberMutationand the rest offeatures/moderation/hooks.tsremain 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 anAdvanceddisclosure 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 toAdmin.AdminConsolePanelwith Reports / Feedback / Staffing tabs (Staffing gated onrole === "operator"); reports and feedback grouped by community for cross-community triage.scope=allso the existing resolve/cancel/reopen controls can reach every status (open,processing,resolved,dismissed,escalated). The relay's omitted-scope default isescalated-only (the platform-safety backstop);scope=allis explicit and scoped to this console. Full action matrix pertarget_kind(event → delete/kick/ban/timeout/dismiss/escalate; pubkey → ban/timeout/dismiss/escalate; blob → dismiss/escalate); kick is suppressed when the report carries nochannelId. Aprocessingrow stays navigable — enforcement state lives in the detail view. Afailedaction (always pre-mutation) offers a single Cancel & reopen viaPOST /reports/{id}/cancelwith{actionId}fencing; there is no client-side retry.pending/enforcingactions belong to the relay recovery worker and offer no button; a409reloads detail. Lists refetch on back-navigation after a mutation.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.processing— a report enforced then reopened isopenyet still shows itssucceededaction as executed history alongside the resolve form; a reopen does not un-happen a ban. The resolve form gates onopenstatus alone.resolved/dismissed/escalated) can be returned toopenfor re-triage viaPOST /reports/{id}/reopen. Reopen never reverses applied enforcement, and the copy says so.new/reviewed/archived) with a generation-fenced attachment viewer. A purged source community severs provenance tonullrather than deleting the row; severed rows bucket under a "source community removed" heading.useUsersBatchQueryand 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 config-backed principal targeted by add, role change, or remove returns the relay's409 Conflict, classified via the typedrelayStatus(never by matching message text) and rendered with config-backed copy. Roster withconfig/owner_fallback/dbsource labels; config-backed entries disable remove client-side. In-place role change via<select>callingputAdminOperator; the relay responds with the effectiveOperatorEntry(pubkey,effectiveRole,sources) in the same shape as the roster list. Read-only badge for config-managed entries.useTimeoutStateclears the store when derived state isINACTIVEbut the store is still active; the 1s tick is scoped to entries with a knownexpiresAtMsand owned byComposerTimeoutBanner(mounted only while a timeout is active), whileChannelPanesubscribes to the booleanuseTimeoutActive()— the pane re-renders when a timeout starts or ends, not every second.useMembersSidebarModerationticksnowMsonly while the sidebar is open, the viewer can moderate, and some member has a futuremutedUntil; the tick after the last expiry stops the interval.sonnertoasts; failures surface the relay's parsed errormessage, not raw JSON.tokenModeanddisabledauth modes render 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 withbodyComplete: true) discards the snapshot, unlocks controls, and the corrected submit uses a fresh payload. On success the toast derives from the authoritativeAdminReportResolution(activeAction.actionwhen present; otherwisestatusfor decision-only outcomes) — never from the mutable form selection.The native mutation commands — including operator delete — reject with a typed
AdminMutationErrorcarrying the relay's HTTP status (relayStatus,nullwhen no verdict exists) and abodyCompleteflag that istrueonly when the full response body was read.Related: #3777 (relay
OPERATOR/MODERATORrole model + NIP-98 auth +admin_apiNIP-11 advertisement — provides the runtime and the discovery field this console consumes)Related: bb-public#339 (Phase 4 rollout config)
Screenshots
Captured headless at
197f0ba0cagainst the mock bridge (1280×720).Setup
Origin setup — Advanced disclosure open, idle state

Authorized console — all three tabs

Reports
Reports queue — open, processing, resolved across two relay groups

Open report detail — reporter, target, message snapshot, resolve form

Processing report — PROCESSING/HARASSMENT, ban enforcing state

Resolve form — Delete selected, audience disclosure copy

Resolved report — enforcement history block

Feedback
Feedback list — community grouping with status badges

Feedback detail — mutable status control (operator mode)

Feedback detail — passive status badge (read-only/disabled mode)

Staffing
Staffing roster — config/owner_fallback/db source labels

Add form — new pubkey filled, ready to submit

Duplicate rejection — inline error for existing principal
