Skip to content

feat(desktop): relay operator console as the single Moderation surface - #4768

Open
wpfleger96 wants to merge 10 commits into
mainfrom
wpfleger/desktop-admin-surface
Open

feat(desktop): relay operator console as the single Moderation surface#4768
wpfleger96 wants to merge 10 commits into
mainfrom
wpfleger/desktop-admin-surface

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Aug 4, 2026

Copy link
Copy Markdown
Member

Adds an in-app admin console for relay operators and makes it the single Moderation surface under Settings. 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 bug this fixes

The moderation settings section was defined in settingsSections but never added to any group in settingsNavGroups, so the sidebar never rendered it and nothing deep-linked to it — the surface has been unreachable since #1617. This wires moderation into the Communities nav group (after community-members), repoints it at the relay admin console, and drops the separate admin-console section id so a single surface owns relay-level trust & safety.

Single Moderation surface

Relay operators/moderators are the only actors with a Moderation entry; the console is gated behind the relay OPERATOR/MODERATOR roles (RELAY_OPERATOR_PUBKEYS config → owner-fallback → relay_operators DB) and every /api/admin/v1/* route returns 403 to anyone else. Community self-administration (community-members / "Invites") and in-channel enforcement (the 9040–9043 signed commands) are a separate axis and are untouched.

The previously-duplicate community report queue (ModerationQueueCard + moderationQueue.ts and its three now-dead hooks — useModerationReportsQuery, useModerationAuditQuery, useResolveReportMutation) was unreachable dead code and is deleted in its own commit. useBanMemberMutation and the rest of features/moderation/hooks.ts remain — the members sidebar and message menu consume them.

Nav visibility gate

The Moderation entry renders only when the console can plausibly authorize:

  • No origin (no NIP-11 advertisement, no saved manual origin) → hidden.
  • Saved manual origin exists → always visible. The Advanced control that edits or clears a bad URL lives inside the surface, so a user who saved a wrong or token-mode origin can still reach the only UI that fixes their own state.
  • Advertised-only origin → visible on nip98Authorized / disabled or a transport flake (networkOrIntercepted), hidden on a definitive non-admin verdict (nip98Denied / tokenMode / notAdminApi).

A probe network failure is never treated as unauthorized — a saved or advertised origin keeps the entry visible and lets the surface render its distinct error state rather than silently disappearing on a flake.

Discovery and connection

  • On mount the console reads the connected relay's NIP-11 document for the optional admin_api field to auto-discover the origin — zero manual input on the happy path.
  • The advertised origin is untrusted input: revalidated through AdminOrigin::parse before use; an absent or invalid value falls back to a manual field (pre-filled when discovery succeeds).
  • A saved manual origin takes precedence over discovery — an operator's explicit choice is never overridden.

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

  • AdminOrigin value object: validates scheme + host + optional port, rejects credentials/path/query/fragment; http:// only for loopback, https:// otherwise.
  • AdminRoute closed enum: no IPC surface accepts arbitrary URLs or paths, so the NIP-98-signed URL is byte-identical to the fetched URL.
  • Dedicated no-redirect reqwest client as an SSRF guard: a relay 3xx is surfaced as an error and the NIP-98 header is never forwarded across origins.
  • admin_probe targets the signed GET /probe endpoint and returns a typed state (nip98Authorized / nip98Denied / tokenMode / disabled / notAdminApi / networkOrIntercepted); nip98Authorized only on an authenticated 2xx, carrying the relay-resolved role/source from the ProbeResponse ({status, authMode, role, source, canAct, canStaff}, camelCase) so an operator reaches role/source badges and the Staffing tab. role/source are null in token/disabled modes. Strict typing of the probe body rejects unrelated JSON endpoints as notAdminApi.
  • NIP-98 signing via AppState::signing_keys() (errors cleanly in recovery mode), with one retry on 401 using a fresh signed event.
  • Response size bounded by a Content-Length preflight and a streaming byte counter; per-pubkey origin storage with atomic 0o600 writes.

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

  • AdminConsoleSettingsCard: discovery + probe flow, per-pubkey state, role/source badges when authorized, honest copy for every probe state. The manual origin field is collapsed under an Advanced disclosure whose controls stay mounted.
  • AdminConsolePanel with Reports / Feedback / Staffing tabs (Staffing gated on role === "operator"). Reports and feedback are grouped by community (communityHost) for cross-community triage.
  • Reports: full action matrix per target_kind (event → delete/kick/ban/timeout/dismiss/escalate; pubkey → ban/timeout/dismiss/escalate; blob → dismiss/escalate). Kick removes the target from the report's associated channel, so it is suppressed when the report carries no channelId — the relay would reject it with invalid_action_for_target. A processing report row stays navigable — its enforcement state (pending/enforcing/succeeded/failed) lives inside the detail view, so the row must open. A failed action is always pre-mutation and offers a single Cancel & reopen button: it returns the report to open for a fresh resolution via POST /reports/{id}/cancel with {actionId} fencing the cancel to the observed action. There is no client-side retry — composing cancel + a new resolve would imply an atomicity the relay does not provide. pending/enforcing actions are owned by the relay recovery worker and offer no button; a 409 is authoritative and reloads detail. Each resolve attempt carries a client-generated requestId. Report and feedback lists refetch on back-navigation after a mutation so a resolved/dismissed status is reflected without a tab switch.
  • Enforcement history: the report detail LATERAL returns the governing action even after the report leaves processing. A report enforced then reopened is open yet still carries its succeeded action — the ban actually ran, and a later reopen does not un-happen it. The console renders that action as executed-enforcement history and still shows the resolve form for re-triage; the resolve form gates on open status alone (a live/failed action keeps the report processing, never open).
  • Reopen: terminal reports (resolved / dismissed / escalated) show a reopen 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, and the copy says so, more emphatically when the report carries an actionId. The per-attempt requestId is reused on a 409 (report moved to processing) so the relay dedupes.
  • Feedback: list, detail, and status control (new/reviewed/archived) with a generation-fenced attachment viewer (image/* auto-loads; other MIME types load on demand). Feedback is deployment-global operator evidence: a purged source community severs communityId/communityHost to null rather than cascade-deleting the row, so severed rows bucket under a "source community removed" heading in the list and render em-dashes for the absent community fields in detail.
  • Staffing: add operator (pubkey + role), roster with config / owner_fallback / db source badges; config-backed entries disable remove client-side and surface a 409 conflict as a human-readable message.
  • Report, feedback, and enforcement actions confirm with sonner success toasts; mutation failures surface the relay's parsed error message through sonner error toasts rather than raw JSON in the component.

UI invariants

  • The resolve form gates on open status alone. A live or failed action keeps the report processing (never open), so an in-flight enforcement never surfaces the form; a reopened-after-enforcement report is open and correctly shows both the executed-enforcement history and the form.
  • A succeeded action on a terminal or reopened report renders as executed-enforcement history, not as inconsistent state — the enforcement ran and is reported honestly.
  • Post-mutation delivery states render on a resolved report, never as an enforcement failure.
  • tokenMode and disabled auth modes render read-only, with no action or staffing capabilities.

Related: #3777 (relay OPERATOR/MODERATOR role model + NIP-98 auth + admin_api NIP-11 advertisement — provides the runtime and the discovery field this console consumes)
Related: bb-public#339 (Phase 4 rollout config)

@wpfleger96
wpfleger96 requested a review from a team as a code owner August 4, 2026 18:46
@wpfleger96
wpfleger96 force-pushed the wpfleger/desktop-admin-surface branch 4 times, most recently from 42e67e3 to 9bb1666 Compare August 6, 2026 17:49
wpfleger96 added a commit that referenced this pull request Aug 7, 2026
…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>
@wpfleger96 wpfleger96 changed the title feat(desktop): add in-app admin console for relay operators feat(desktop): role-based admin console — Phase 3 rework (v4 contract) Aug 7, 2026
@wpfleger96
wpfleger96 force-pushed the wpfleger/desktop-admin-surface branch from fa29ae1 to 99527b1 Compare August 11, 2026 00:04
@wpfleger96 wpfleger96 changed the title feat(desktop): role-based admin console — Phase 3 rework (v4 contract) feat(desktop): in-app admin console for relay operators Aug 11, 2026
@wpfleger96
wpfleger96 force-pushed the wpfleger/desktop-admin-surface branch from 99527b1 to 6bd27a2 Compare August 11, 2026 00:09
Hayt and others added 5 commits August 12, 2026 16:02
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>
…n-surface

* origin/main:
  chore(release): release Buzz Desktop version 0.5.11 (#5714)
  feat(acp): report standard adapter usage (#4950)
  fix(mobile): settle hydrated threads on latest reply (#4702)
  perf(desktop): persist channel snapshot hash (#5684)
  fix(agent): raise output limit and allow 3 recoveries (#5475)
  fix(desktop): defer foreground resume work (#5696)

Signed-off-by: Hayt <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
@wpfleger96
wpfleger96 force-pushed the wpfleger/desktop-admin-surface branch from 6bd27a2 to fbb8b6c Compare August 13, 2026 01:08
@wpfleger96 wpfleger96 changed the title feat(desktop): in-app admin console for relay operators feat(desktop): relay operator moderation console as the single Moderation surface Aug 13, 2026
@wpfleger96 wpfleger96 changed the title feat(desktop): relay operator moderation console as the single Moderation surface feat(desktop): relay operator console as the single Moderation surface Aug 13, 2026
Hayt and others added 5 commits August 12, 2026 23:03
…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>
…n-surface

* origin/main:
  Harden shared agent instruction review (#4220)

Signed-off-by: Hayt <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant