feat(desktop): relay admin console for the /api/admin/v1 operator surface - #4768
feat(desktop): relay admin console for the /api/admin/v1 operator surface#4768wpfleger96 wants to merge 32 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>
Add a NIP-98 client for the /api/admin/v1 relay API, surfaced as an "Admin console" 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() (Err in recovery mode); 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 Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Wire "moderation" into settingsNavGroups under the Communities group. The entry was defined in settingsSections but never added to a nav group, so the sidebar never rendered it and nothing deep-linked to it — it has been unreachable since #1617. Repoint the "moderation" section to the relay admin console (AdminConsole SettingsCard), retitle it "Moderation", and drop the separate "admin-console" section id so a single surface owns relay-level trust & safety. Gate the nav entry behind origin + probe resolution: hidden with no origin, always visible for a saved manual origin (the Advanced control that fixes a bad URL lives inside the surface), and — for an advertised-only origin — visible on authorization or a transport flake but hidden on a definitive non-admin verdict. Group reports and feedback by community for cross-community triage, and collapse the manual origin field under an Advanced disclosure that keeps its controls mounted. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The community-mode moderation queue was reachable only through ModerationQueueCard, which the previous commit removed from the nav — the relay admin console is now the single moderation surface. Delete the card, its triage library (lib/moderationQueue.ts) and that library's tests, and the three hooks the card was the sole consumer of (useModerationReportsQuery, useModerationAuditQuery, useResolveReportMutation). The remaining moderation hooks — ban/timeout/unban and report submission — stay: the members sidebar and message menu still consume them. With the two report/audit read hooks gone, the write-invalidation now touches only the surviving restrictions read. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Add a reopen affordance to the report detail: terminal reports
(resolved | dismissed | escalated) show a form that moves the report
back to open for re-triage via POST /reports/{id}/reopen.
Reopen is re-triage only — it does not reverse any enforcement already
applied. The copy says so, and warns more emphatically when the report
carries an actionId (an enforcement action ran while it was resolved).
A per-attempt requestId is generated once and reused on a 409 retry so a
report that has moved to processing dedupes on the relay, mirroring the
resolve flow's idempotency.
Surface confirmation on the report and feedback actions with sonner
success toasts (resolve, retry, cancel, reopen, feedback status).
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…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>
Adopt the ratified relay->desktop action record JSON: add actorPubkey, actorRole, and errorMessage; replace the invented expirationSecs with the absolute expiresAt enforcement expiry; drop reportId (the parent report already carries provenance). Drop the now-absent expirationSecs from the retry body and rebuild the enforcement fixture from the wire shape. Type-level slice only; cancel-route wiring and feedback nullable-community shapes wait on the relay batch. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Wire the admin console to the relay's cancel route and align the report
detail to the ratified LATERAL semantics.
Cancel replaces the composed client-side retry. A failed enforcement is
always pre-mutation, so it is always cancellable; cancel returns the
report to `open` for a fresh resolution. Composing cancel + resolve would
imply an atomicity the relay does not provide, so the console offers a
single "Cancel & reopen" button on `failed` only — pending/enforcing are
owned by the relay recovery worker — POSTs `{actionId}` to
/reports/{id}/cancel, treats 409 as refresh-detail, and reloads rather
than consuming the response body.
Honest history: the detail LATERAL returns a succeeded action even on a
reopened (open) report — the ban actually ran, and a later reopen does
not un-happen it. The enforcement block now renders whenever an action is
present (live or history) and the resolve form gates on `isOpen` alone, so
a reopened-after-enforcement report shows both the executed-enforcement
history and the resolve form for re-triage. The old `isOpen && !activeAction`
gate wrongly stranded the operator on such a report.
Tighten the wire seam to match the relay's no-skip_serializing_if
serialization: reason/expiresAt/errorMessage are required-nullable, and
resolve/cancel return the `{status, activeAction}` envelope. Feedback
communityId/communityHost are nullable — a purged source community severs
provenance to NULL while retaining the row as operator evidence; severed
rows bucket under a "source community removed" heading and render em-dashes
in detail.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…errors The desktop admin console had four e2e defects. Probe discarded role/source: it hit /reports and hardcoded role: None behind a comment referencing X-Admin-Role headers the relay never sends, so the Staffing tab and role badges were unreachable for operators. Probe now targets the signed /probe endpoint and parses the ProbeResponse contract, carrying the relay-resolved role/source through. Reports and Feedback lists served stale rows after a detail-view mutation until a tab switch forced a remount. Each tab now bumps a list generation fence on mutation completion so back-nav refetches. Kick was offered on every event report, but the relay 400s it when the report carries no channel. It is now suppressed when channelId is null. Mutation errors rendered raw admin API error JSON inline; they now route through toast.error with the parsed relay message. The seed script inserted no channel_id on any report, so the failed-enforcement -> Cancel & reopen path was unreachable locally. It now seeds a channel and an event report carrying it. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The probe accepted every structurally deserializable 2xx as Nip98Authorized and forwarded whatever role/source strings arrived, without requiring status == "ok", authMode == "nip98", a recognized non-null role/source, canAct == true, or canStaff == (role == operator). A token-mode, error-status, or role/capability-mismatched body reaching the authenticated retry authorized the console and could expose Staffing against a relay that denied it — a fail-open network-boundary classification. The unauthenticated 200 branch matched authMode alone. Add typed ProbeRole/ProbeSource enums (closed vocabularies) and two validators on ProbeWire: authorized_principal returns the typed principal only when the complete nip98 invariant holds; is_coherent_disabled gates the disabled branch on the full disabled-mode shape. Both call sites fail closed to NotAdminApi otherwise. parse_probe stays pure structural deserialization with Serde's unknown-field tolerance for forward compatibility. Route FeedbackStatusControl mutation failures through toast.error(adminErrorMessage(e)) like the other three handlers, so a relay error envelope no longer renders as raw JSON inline. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The resolve/reopen retry paths preserved their idempotency requestId only when the error string contained "409"/"processing". The native layer serializes lost-response failures as "relay unreachable: …" and "admin response stream error", so every ambiguous transport/read failure cleared the UUID and the retry became a brand-new command — defeating idempotency on exactly the failures it exists to contain (two-operator interleave: A reopens, response lost, B resolves, A's retry with a fresh UUID clobbers B's later action). Replace the string match with a typed AdminMutationError carrying the relay's HTTP status. The UI preserves the requestId unless the relay definitively rejected the request pre-commit (a non-409 4xx); it preserves on null status (transport/pre-send), 409, 5xx, and a lost response body, so the relay dedupes the retry. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…grade
P1 — Operator removal now requires an explicit confirmation dialog (AlertDialog)
before deleteAdminOperator is invoked. The dialog recaps the target operator's
truncated pubkey and effectiveRole. When the acting user is removing their own
entry (op.pubkey === pubkey), a distinct self-removal warning is shown. Cancelling
the dialog leaves the row intact and never calls deleteAdminOperator.
P2 — AdminConsolePanel now resets activeTab to 'reports' when the active tab
becomes unauthorised for the current role (e.g. staffing while !isOperator after
an operator→moderator transition). The guard is written against tab-visibility
(the Set TabBar would render for the role) rather than a hard-coded role check,
so it generalises to future tabs. A data-testid='reports-tab' wrapper is added
to the ReportsTab render site to make the reset observable in tests.
Tests: four new jsdom tests cover the dialog state machine and the role-transition
reset. Mutation evidence per finding:
P1 cancel: bypass dialog (call deleteAdminOperator directly from button) → RED
P1 confirm: same bypass → RED (dialog never opens, Confirm absent)
P1 self: same bypass → RED (dialog never opens, self-warning absent)
P2: remove reset useEffect → RED ('reports-tab content must render after reset')
A jsdom/Radix compatibility shim (dispatchEvent patch) is added at module scope
to swallow cross-context CustomEvent arguments that Radix react-dismissable-layer
and react-focus-scope dispatch to document/elements on dialog mount — a jsdom 27
version-boundary issue, not a production defect.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…constructors
Node 24 already defines Event and CustomEvent on globalThis, so the
for..in copy-when-absent loop in test-jsdom-setup.mjs never replaces them.
Radix UI constructs events from those Node built-in globals; jsdom 27
correctly rejects the resulting instances in dispatchEvent (constructor
mismatch). The prior commit worked around this with a try/catch monkeypatch
on document.dispatchEvent and HTMLElement.prototype.dispatchEvent that
silently swallowed every matching TypeError across the entire 45-test
suite — masking any future production bug that dispatches a non-Event.
Fix: assign globalThis.Event and globalThis.CustomEvent from the jsdom
window after the copy loop so Radix's instances are recognised as valid.
Remove the monkeypatch entirely. Native dispatchEvent validation is
restored — document.dispatchEvent({type:'malformed'}) throws TypeError
as expected under the corrected setup.
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: four P2 desktop integration defects.
Reviewed head 662db64518d72ba96a3e1726765baae71d203644 against exact base a3730784fc851bb1125b40cca9b0a30788a293c1.
The product boundary is a relay-operator/moderator console, distinct from community-owner administration. Discovery must remain untrusted until the operator confirms the origin; the relay remains authoritative for roles, report transitions and public enforcement notices. The four inline findings concern unreachable manual setup, non-escalated report access, undisclosed public-reason semantics, and retries that claim the wrong action succeeded. They do not require changing the relay's intentional escalated-only default or weakening discovery trust.
Coverage: Settings navigation/discovery and identity/origin transitions; native origin, route/auth, bounded-body/error plumbing; report list/detail/resolve/reopen/cancel and idempotency; feedback attachments/status; staffing confirmation/role changes. Compared client routes and closed action/status/role sets to exact-head relay producers. The previous staffing-confirmation, hidden-active-tab and unconditional request-ID-clearing defects are fixed in this head and are not repeated. Community signed-command enforcement and mobile/web UI are not changed by this desktop PR; they were not independently reviewed as whole features.
Validation was source/diff and existing-test inspection only under the read-only automation policy. Key source blobs were checked against the pinned Git tree. No checkout, build, test execution or live relay/app run; this is not a runtime pass.
Finding #1 (nav unreachable): remove the discovery-based visibility gate and redirect for Moderation. The nav entry is always present so an operator can enter a manual origin when NIP-11 is absent/invalid/pending. Auto-probe trust boundary preserved — nothing contacts the origin until explicit Save. Add settingsNavModerationBehavior tests covering all discovery states and the mutation. Finding #2 (full report queue inaccessible): add typed scope optional field through TS AdminReportsQuery -> Tauri AdminReportsQuery -> AdminQuery route serialization. ReportsTab calls scope=all so the existing resolve/cancel/reopen controls can reach open, processing, resolved, and dismissed states. Relay's omitted-scope default remains escalated-only for every other caller. Add processing to relay explicit-status validation. Add route serialization and desktop IPC tests with mutations. Finding #3 (reason audience undisclosed): add dynamic audience copy beneath Reason (optional) based on selected action: delete -> user + publicly in room; kick/ban/timeout -> user only; dismiss/escalate -> reporter only. Add jsdom tests for each family; static affected-user-only copy fails delete and decision-only mutations. Finding #4 (retry command divergence): freeze the whole submitted payload {requestId, action, reason, expirationSecs} on first submit. After ambiguous failure, retain snapshot and disable action/reason/ duration controls so edits cannot diverge from the frozen command. On definitive pre-commit rejection (non-409 4xx), discard snapshot and re-enable controls. Derive success toast from AdminReportResolution (activeAction.action, then status mapping) rather than mutable form state. Upgrade existing resolve requestId tests to whole-payload equality. Add frozen-payload, locked-controls, response-toast, and unlock-on-definitive-4xx jsdom tests. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
IMPORTANT 1 — replace predicate-replica navigation tests with real jsdom harness that renders SettingsView and controls discovery state: - Delete settingsNavModerationBehavior.test.mjs (123 lines of test-local predicate replicas that passed even with old production hook restored) - Add settingsNavModeration.jsdom-test.mjs: 6 tests rendering the real SettingsView via React createRoot + jsdom; assert nav visibility and no redirect under absent/error/pending discovery; no-probe-before-save asserts zero admin_probe calls before explicit Save; restoring the old return false mutation makes 4/6 RED IMPORTANT 2 — force form/response disagreement in toast tests: - resolve-toast-from-response-ban: selects Dismiss, relay returns activeAction.action="ban"; asserts "Ban" toast; mutation actionLabel(selectedAction) → "Dismiss" toast → RED - resolve-toast-from-response-escalated: selects Dismiss, first attempt fails with transport error (freezes form), retry returns status=escalated; asserts "Escalate" toast; mutation → "Dismiss" → RED Coverage gaps (Paul-requested): - resolve-definitive-4xx-unlocks-controls: extended to change action/reason after unlock and assert second IPC has fresh requestId + corrected body - Table-driven resolve-409/5xx/truncated-body-whole-payload: each ambiguity class independently proves byte-for-byte command freezing on retry - relay report_status_accepts_processing: extracts REPORT_STATUS_ALLOWLIST const and adds unit test referencing it; mutation removes "processing" → RED; omitted-default and scope=all tests stay green Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
a2fdea7 to
b522404
Compare
Switch the 409/5xx/truncated-body table-driven cases from Ban to Timeout with a nontrivial expirationSecs (3600s). Each ambiguity class now selects Timeout, enters a duration, submits, and asserts the duration control is locked after failure. The retry uses assert.deepEqual to prove the complete frozen payload — requestId, action, reason, and expirationSecs — is sent byte-for-byte. Mutating the production IPC writer to always omit expirationSecs makes all three cases RED. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…n-surface * origin/main: docs: add review-proven failure-path & async-state rules to AGENTS.md (#7061) fix(desktop): back split thread headers (#7137) add public descriptions to agent personas (#7126) feat(desktop): add protected-build Bestie experiment (#6902) fix(relay): reject a frame on its own acknowledgement channel (#6961) fix(acp): wake agents from workflow messages (#6953) feat: render agent avatars as squircles (#7106) fix(ci): salvage Codex review output on PTY-shutdown hang (#7042) fix: retrieving cold memories; add regression task (#6950) Enforce NIP-OA authorization time bounds (#7004) feat(db): configurable writer session timeouts (lock, idle-txn, statement) (#6229) feat(desktop): use segmented controls for channel creation (#6845) feat(buzz-agent): surface stop reason and silent-turn WARN in telemetry (#7038) fix(desktop): surface channel history load failures (#7013) fix(composer): polish automatic mentions (#6956) fix(desktop): resolve bundled sidecar on cheap path and bound login-shell spawns (#6904) perf(mobile): reduce cold startup and channel rendering delays (#6996) feat(mobile): push notifications MVP (#6269) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes requested at head 0b972699c51d010ebdf349498fbe6cbc40468d16, base 4952f5810290d00a1dd9413235b39e9a7154acf6.
P2: Do not let “Add operator” silently demote an existing operator
AdminConsoleStaffingTab.tsx:97–105 sends an immediate role upsert, while the form says “Add operator” / “Add” and defaults to moderator (lines 81, 195, 229). With DB operators A and B, paste an existing operator key into this form and leave the default role: clicking Add changes that account to moderator without a role-change confirmation. If A submits their own key, A loses roster-management access and cannot reverse the change themselves. The new Remove confirmation and self-removal warning protect DELETE only, not this PUT path.
This is confirmed by the actual producer: crates/buzz-relay/src/api/admin/mod.rs:1037–1055 forwards the role to crates/buzz-db/src/store/relay_operators.rs:134–145, whose conflict branch updates the existing role. The last-operator guard at lines 169–174 deliberately permits the demotion when another operator exists. This is accidental individual access loss, not a claim that the deployment can lose its last operator.
Exit criterion: distinguish adding a new principal from modifying one. Reject an already-present key in Add, or explicitly present the existing→requested role change and confirm demotions, including a self-access warning. Keep relay authority and last-operator protection unchanged. Add a real form/IPC regression with two operator rows proving that an existing-key submission does not silently demote, while adding a new moderator still works. The existing event tests cover DELETE confirmation and synthetic role-prop changes, not this PUT submission. This was missed in earlier review rounds; it is not a regression introduced by the four latest fixes.
P2: Accept the relay’s supported .localhost manual origin
desktop/src-tauri/src/commands/admin/origin.rs:123–130 recognizes only bare localhost and loopback IPs. Consequently AdminOrigin::parse("http://admin.localhost:3000") takes the non-loopback HTTP rejection at lines 64–74, so an operator cannot save the repository’s local admin origin. This is an existing producer contract, not a proposed new deployment: Justfile:498 defaults BUZZ_ADMIN_HOST to admin.localhost:3000, and relay api/admin/auth.rs:109–155,534–538 deliberately advertises and verifies HTTP for every .localhost name. Substituting 127.0.0.1 is not equivalent: admin routing checks the configured Host exactly (auth.rs:99–106).
Exit criterion: support explicit manual entry of the relay’s loopback-name contract, preserving the configured hostname for Host/NIP-98 signing and ensuring it connects as loopback. Add a regression for the real http://admin.localhost:3000 value. Do not relax the separate prohibition on automatically probing untrusted advertised internal origins. This is a missed compatibility path, not a regression in the four latest fixes.
Non-blocking P3: Preserve status in read-only feedback detail
AdminConsoleFeedbackTab.tsx:497–507 hides the only detail status representation when canMutate=false; FeedbackFields (lines 340–358) has no passive status row. A disabled-auth reader can see a reviewed/archived badge in the list, then loses that information when opening detail. A passive status badge would preserve the read-only contract without adding mutation controls. This is not an additional merge gate.
Verified scope and limits
All four findings from review 5070332066 are closed: manual setup remains reachable without discovery; scope=all reaches the native query and relay while retaining the relay’s escalated-only default; reason-audience copy matches the actual notices; resolve retries freeze the command and derive success copy from the relay response. DELETE confirmation and hidden-Staffing reset remain present.
The integrated review traced Desktop Settings/Reports/Feedback/Staffing through native admin transport and exact-head relay/DB producers, including authorization, report/action closed sets, mutation retries, capability transitions, and attachment budgets/lifetimes. Community moderation, mobile/web operator UIs, and deployment configuration are outside this Desktop delivery. Reopen replay preserves the original audit and cannot reopen a later resolution again; its editable optional reason is not being promoted into an enforcement blocker. Raw Staffing error presentation remains the declared follow-up. The proposed blanket private-address prohibition on signed requests is not a blocker: the agreed boundary permits manually trusted internal origins after explicit Save, and discovery does not probe its advertised destination.
Source-only validation: immutable GitHub source/diffs and existing regression tests were inspected; key evidence blobs were verified against the pinned Git tree. No PR checkout, build, test execution, or live app/relay run. This is not a runtime pass.
…status badge P1 (staffing duplicate guard): handleAdd now compares the normalized pubkey against the loaded roster before invoking admin_put_operator. A duplicate is rejected with an inline error naming the existing effective role; the Add button is disabled until the list has loaded successfully so the guard cannot be bypassed by a loading/error window. P2 (.localhost origin): is_loopback_host now accepts names ending in .localhost per RFC 6761 §6.3. System getaddrinfo does not reliably resolve .localhost subdomains on Linux/Windows CI runners, so LocalhostDnsResolver pins them to 127.0.0.1 in the admin HTTP client. AdminOrigin preserves the admin.localhost hostname in the canonical URL, HTTP Host header, and NIP-98 signed URL without rewriting to an IP. P3 (read-only status badge): FeedbackDetail now renders a passive data-testid='feedback-status-readonly' badge when canMutate=false, giving the server status a programmatically readable representation without restoring any mutation affordance or duplicating the widget in mutable mode. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…n-surface Conflict: desktop/package.json — 'test' script merge - Our branch: ordinary phase (src/**/*.test.mjs) && jsdom phase (src/**/*.jsdom-test.mjs) - origin/main: ordinary phase added scripts/*.test.mjs glob - Resolution: ordinary phase covers both src/**/*.test.mjs and scripts/*.test.mjs, followed by the jsdom phase unchanged. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…g roster invariant
dns.rs: three unit tests for LocalhostDnsResolver.
- dot_localhost_resolves_to_exactly_loopback_zero_port: admin.localhost
resolves to the singleton 127.0.0.1:0 (not merely some loopback), proving
the pinning branch is taken rather than system GAI. Removing
.ends_with(".localhost") makes this test RED on all platforms.
- multi_level_dot_localhost_resolves_to_loopback: deep.sub.localhost covered.
- non_dot_localhost_delegates_to_system_gai: bare localhost falls through to
system GAI and returns a loopback address, proving the resolver does not
intercept non-.localhost names. Deterministic and offline-safe on all
supported platforms.
AdminConsoleStaffingTab: handleAdd now returns unconditionally when
listState.status !== "ok", closing the mutation boundary at the call site
rather than relying solely on the disabled-button UI gate.
adminConsolePanelEvents.jsdom-test: the staffing-add-duplicate-guard test
now asserts that the existing row still renders with its original "operator"
role after the rejected duplicate submit — the roster must be unmodified.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…n-surface No conflicts. Auto-merge correct: - lib.rs: admin init + command registrations retained (origin/main removed them for a voice-notes PR that didn't include admin surface) - commands/mod.rs: pub mod admin + pub use admin::* retained - buzz-relay admin/mod.rs: origin/main inline + processing-status changes taken as-is (no overlap with desktop admin surface changes) * origin/main: feat(buzz-auth): add production NIP-FI federated assertion runtime (#7109) Hide download action on voice notes (#7182) ci: run PostgreSQL tests in isolated lane (#6730) Add voice notes to desktop messages (#6978) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
reqwest sends HTTP headers in lowercase (host: vs Host:), so the raw-request assertion must be case-insensitive. The invariant being tested is that the Host *value* preserves admin.localhost, not 127.0.0.1 — the case of the header name is irrelevant. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…n-surface * origin/main: feat(desktop): add Pi agent preset (#7208) feat(buzz-agent): add DatabricksAuthCoordinator single-flight OAuth (#5545) fix(desktop): preserve keyring identity during recovery (#7203) feat(mobile): prepare `buzz-push-gateway` for deployment (#7158) ci: relax file-size ceilings by surface (#6485) fix(mobile): isolate extension linker flags; complete iOS build in CI (#7187) chore(ci): lower Codex security review effort (#7179) fix(dev-mcp): extend shell timeout cap to 20 minutes and align outer budgets (#7185) fix(dev): keep the canonical profile when launching from desktop/ (#7143) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
The console covers relay-level operator work (platform-safety reports, product feedback, operator staffing) — not community moderation. Rename the nav entry from Moderation to Relay admin and swap the icon from ShieldAlert to ServerCog to make the relay-operator scope self-evident. Internal changes: - SettingsSection value: "moderation" → "relay-admin" - Nav label: Moderation → Relay admin / icon: ShieldAlert → ServerCog - SettingsSectionHeader title and description updated - nav.ts types/exports: ModerationOriginSource → AdminOriginSource, ModerationNavResolution → RelayAdminNavResolution, shouldShowModerationNav → shouldShowRelayAdminNav - hooks.ts updated to import RelayAdminNavResolution - All affected nav/settings tests updated to use new id and testids Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
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>
…d compat The internal section id was renamed from 'moderation' to 'relay-admin' in the previous commit. That rename is a live URL route contract: existing links and history entries using ?section=moderation silently landed on Profile after the old id was removed from isSettingsSection. Follow the established pattern (doctor -> agents alias in validateSettingsSearch) to add an explicit migration alias so existing links redirect to the renamed console instead of falling back to the default section. Two sites need the alias because they use different inputs: - validateSettingsSearch (settings.tsx): receives the raw URL query string and is called by TanStack Router's validateSearch. Add the moderation -> relay-admin alias here alongside the existing doctor alias. - AppShell.tsx locationSearchSection: reads location.search via useLocation(), which returns the raw URL object bypassing route validation. Apply the same alias before isSettingsSection is called. Export validateSettingsSearch so the regression test can exercise real production route-validation logic directly (mounting SettingsView bypasses validateSettingsSearch and was what let the earlier gap slip past). Add settings.test.mjs with six cases: moderation -> relay-admin, relay-admin passthrough, doctor -> agents still holds, a valid section, an unknown value, and a missing section. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The moderation-migration comment incorrectly claimed it also migrated the "doctor" alias. The code was correct — only "moderation" is handled here — but the comment implied doctor was part of the normalization. Fix the comment to describe only the moderation migration and explain why doctor is not needed on this path: AppShell never carried that alias on main, and route validation rewrites it before rendering. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
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.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.The Relay admin nav entry is always visible — hiding it when NIP-11 discovery is absent, invalid, or pending would lock an operator out of the only place to configure a manual origin. Auth still gates the panel itself; the nav entry is a door, not a credential.
Discovery and connection
On mount the console reads the connected relay's NIP-11 document for the optional
admin_apifield — zero manual input on the happy path. The advertised origin is untrusted input: revalidated throughAdminOrigin::parse, falling back to a manual field 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.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.Console UI (TypeScript,
src/features/admin-console/)AdminConsoleSettingsCard: discovery + probe flow, role/source badges when authorized, honest copy for every probe state, manual origin under anAdvanceddisclosure.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.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.409 Conflict. Roster withconfig/owner_fallback/dbsource badges; config-backed entries disable remove client-side.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 commands 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.Deferred
AdminConsoleStaffingTab.tsxrenders non-409staffing mutation errors via rawe.messagerather thanadminErrorMessage(e)(the409branches and the Reports/Feedback handlers already use friendly copy). Pre-existing, error-presentation only — no effect on auth or authorization.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
Settings → Relay admin nav entry

Origin setup — Advanced disclosure open, idle state

Authorized console — OPERATOR/DB badge, 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 badges

Add form — new pubkey filled, ready to submit

Duplicate rejection — inline error for existing principal
