Skip to content

feat(desktop): relay admin console for the /api/admin/v1 operator surface - #4768

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

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

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Aug 4, 2026

Copy link
Copy Markdown
Member

Adds a relay admin console under Settings — the single surface for relay-level operator work — replacing the previously unreachable moderation section. Operators authenticate with their own Nostr key over NIP-98 — no browser extension, no bearer token — to view deployment-wide moderation reports and product feedback, resolve/dismiss/escalate/reopen reports, update feedback status, and manage the operator/moderator staffing roster.

Nav reachability

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

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

Discovery and connection

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

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

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

Relay admin console

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

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

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

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

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)

Screenshots

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

Setup

Origin setup — Advanced disclosure open, idle state
Relay admin panel with Advanced: admin origin disclosure open before probe

Authorized console — all three tabs
Authorized relay console showing Connected as operator status and Reports/Feedback/Staffing tabs

Reports

Reports queue — open, processing, resolved across two relay groups
Reports tab showing community-grouped list with spam/open, harassment/processing, illegal/resolved rows

Open report detail — reporter, target, message snapshot, resolve form
Open spam report detail with reported message content and Delete/Kick/Ban/Timeout/Dismiss/Escalate action buttons

Processing report — PROCESSING/HARASSMENT, ban enforcing state
Processing harassment report detail showing enforcement state with ban action in enforcing status

Resolve form — Delete selected, audience disclosure copy
Resolve form with Delete action selected showing "Sent verbatim to the affected user and posted publicly in the room" audience copy

Resolved report — enforcement history block
Resolved/illegal report detail showing Resolved by, Resolved at, and Act ID fields from completed enforcement

Feedback

Feedback list — community grouping with status badges
Feedback tab showing relay.example.com group with new/reviewed items and source-community-removed group

Feedback detail — mutable status control (operator mode)
Feedback detail with new/reviewed/archived status tab control visible for mutation

Feedback detail — passive status badge (read-only/disabled mode)
Feedback detail in disabled mode showing passive "Status NEW" badge with no mutation control

Staffing

Staffing roster — config/owner_fallback/db source labels
Staffing tab showing three operators with config, owner_fallback, and db source labels

Add form — new pubkey filled, ready to submit
Staffing add form with a new 64-hex pubkey entered and moderator role selected

Duplicate rejection — inline error for existing principal
Staffing add form showing "Already an operator: operator. Use Remove to revoke before re-adding with a different role." error for an existing config-backed operator

Post-review fixes

P2: stale principal after self-demotion/removalAdminConsolePanel now accepts an onSelfMutation callback and passes it to StaffingTab. After a successful role change or removal targeting the current principal's own pubkey, the callback fires runProbe(savedOrigin) in the parent, refreshing the role badge and tab visibility (Staffing tab disappears on self-demotion) without any manual re-probe.

P2: 409 misclassification in Staffing — The adminMutationRelayStatus === 409 hardcode in all three mutation paths (handleRoleChange, handleConfirmRemove, handleAdd) is replaced with adminErrorMessage(e). A 409 now surfaces the relay's parsed error body directly — distinguishing config-backed key errors from last-operator conflicts. Previously, both 409 variants showed the same "config-backed" copy, hiding the relay's "add a replacement operator first" recovery guidance when it was a last-operator conflict.

@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
@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
wpfleger96 added a commit that referenced this pull request Aug 13, 2026
…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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Changes requested

  • [P1] Do not auto-probe an unrestricted relay-advertised origindesktop/src-tauri/src/commands/admin/mod.rs:938-949

    admin_api is 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 contact http://localhost:<port> (the client pins localhost to 127.0.0.1) or HTTPS private/link-local targets. If the target returns 401 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-onlydesktop/src/features/admin-console/AdminConsoleSettingsCard.tsx:397-480

    disabled currently renders the same AdminConsolePanel as 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 is disabled.

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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Two security/resource-boundary blockers remain on this head:

  1. Do not auto-probe a relay-advertised admin origin under the manual-origin trust policy. The NIP-11 admin_api value is explicitly untrusted, but admin_origin_from_nip11 passes it through the same AdminOrigin::parse policy 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 with 401 WWW-Authenticate: Nostr. A malicious connected relay can therefore advertise http://127.0.0.1:<port> / localhost (the client explicitly pins localhost to 127.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.rs currently 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.

  2. Bound attachment preview work across the whole feedback item, not only per response. parseImetaAttachments accepts and returns every valid imeta tag, FeedbackDetail mounts an AttachmentViewer for every result, and each image/* 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 of imeta entries, 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.

@wesbillman
wesbillman dismissed their stale review August 26, 2026 15:57

Accidental duplicate from a concurrent automated recovery. The earlier marked review 5032499207 is authoritative.

@wpfleger96
wpfleger96 marked this pull request as draft August 27, 2026 15:42
wpfleger96 added a commit that referenced this pull request Aug 28, 2026
…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>
wpfleger96 added a commit that referenced this pull request Aug 28, 2026
…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>
@wpfleger96
wpfleger96 force-pushed the wpfleger/desktop-admin-surface branch from ff769d3 to 587d23d Compare August 28, 2026 17:12
@wpfleger96
wpfleger96 marked this pull request as ready for review August 28, 2026 18:03

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Changes requested

  • [P1] Do not automatically probe an unrestricted relay-advertised origindesktop/src-tauri/src/commands/admin/mod.rs:938-950

    admin_api is 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 answers 401 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 itemdesktop/src/features/admin-console/AdminConsolePanelHelpers.tsx:243-272

    parseImetaAttachments returns every valid imeta tag, FeedbackDetail mounts every result (AdminConsoleFeedbackTab.tsx:420-465), and each image/* 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 documenteddesktop/src/features/admin-console/AdminConsoleSettingsCard.tsx:470-480

    A coherent disabled probe 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 programmaticallydesktop/src/features/admin-console/AdminConsoleFeedbackTab.tsx:352-375

    The current status is communicated only through button styling (variant and ring classes). The three ordinary buttons have no aria-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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

…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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Duncan and others added 2 commits September 21, 2026 11:36
…n-console

ModerationQueueCard.tsx: keep deletion (replaced by AdminConsoleSettingsCard
in this branch); main's update to that file is superseded.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ge in staffing

P2-1 (stale principal after self-demotion/removal):
AdminConsolePanel accepts onSelfMutation callback and passes it to
StaffingTab. handleRoleChange and handleConfirmRemove call onSelfMutation
when the mutated pubkey equals the current principal. AdminConsoleSettingsCard
passes runProbe(savedOrigin) as the callback so the role badge and Staffing
tab visibility update immediately after self-demotion or self-removal.

P2-2 (409 misclassification):
Replace the adminMutationRelayStatus === 409 hardcode in handleRoleChange,
handleConfirmRemove, and handleAdd with adminErrorMessage(e). A 409 now
surfaces the relay parsed error body (config-backed message or last-operator
recovery message) instead of a generic copy that hid the relay distinct
guidance for last-operator conflicts.

Tests: 3 updated 409 tests now cover both config-backed and last-operator 409
sub-cases; 3 new self-mutation callback tests (self demotion, self removal,
other-operator mutation does not fire). All 68 jsdom tests pass.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Mount the real AdminConsoleSettingsCard to verify the Settings→panel
wiring at AdminConsoleSettingsCard.tsx:462 (onSelfMutation={() =>
runProbe(savedOrigin)}).

The existing staffing-self-demotion-fires-onSelfMutation test mounts
AdminConsolePanel directly with the callback as a prop — it proves the
StaffingTab guard fires but cannot detect a missing wiring at the
SettingsCard level. Two new tests close the gap:

- settings-card-self-demotion-reruns-probe: navigates to the Staffing
  tab via mountCardFull (SettingsCard + CommunitiesProvider), fires a
  self-role-change, and asserts admin_probe is called a second time and
  the Staffing tab disappears from the re-rendered UI.
  RED when onSelfMutation wiring is removed from SettingsCard.tsx:462.

- settings-card-other-demotion-does-not-reruns-probe: negative control
  — demoting a different operator must not trigger runProbe.
  RED when the op.pubkey === pubkey guard in StaffingTab is removed.

Co-authored-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Changes requested: one P2 origin-switch regression

Reviewed head 78da302e53aea6b90a37ff38757762977f8cbf35 against exact base/merge-base 5079c770fe30bb3d8204822ce6c2431eacac6d4b, focusing on the corrective delta since efa68d25829d357b7b557cbe92eb2e48cd094be0. Relay staffing remains distinct from community administration, and each relay is authoritative for its own principal’s permissions.

P2: Fence the self-mutation refresh to its original origin/session

The new Settings callback captures savedOrigin, but Staffing invokes that callback after awaiting its mutation, including after the panel has unmounted. Advanced/input/Save remain usable during the mutation.

Reproduction: with two effective operators on relay A and the current identity independently authorized on relay B, start self-removal on A and delay its successful response. Save B as the admin origin and let B’s probe authorize. When A’s DELETE resolves, the retained callback calls runProbe(A). That starts a fresh probe, superseding B’s probe/state; A now returns denied. Settings still has B saved but displays Access denied and hides B’s otherwise-authorized console. Self-demotion similarly lets A’s moderator result hide Staffing on B where the identity is still an operator.

The existing probe abort check does not fence this: the stale callback creates the newest controller. The origin edit unmounts the child, not the Settings session, and the callback does not check the current session/origin. This is a user-visible cross-origin state regression introduced by the refresh wiring, not a server authorization bypass. Re-probe recovers it, but the wrong denial/role persists until then.

Exit: ignore a self-mutation completion if its captured origin/session is no longer current, before it starts a probe. Reuse the existing context-fencing pattern rather than adding a new lifecycle owner. Add a Settings-level deferred-mutation regression: authorize B before settling A’s mutation, then assert B’s authorization/panel survives and the stale completion cannot replace it.

Resolved findings and validation boundary

The previous two P2 findings are fixed on the ordinary same-origin path: self-demotion/removal now refreshes the parent principal, and Staffing preserves the relay’s distinct immutable-config versus last-operator recovery text. The new Settings-level self-demotion test exercises the actual parent wiring. Production source is unchanged between 0ab00433 and 78da302e; that last commit adds tests only.

This was source/diff and test-source inspection on the pinned Blox host, with independent metadata review. No PR checkout, build, test execution, or live reproduction was performed. The one-time exact-head CI snapshot had 53 successful checks, 27 skipped, and two still running; it is not an all-green claim. Existing accepted discovery/trust behavior and unrelated base changes were not reopened. The sole requested fix is the stale-origin callback above.

A self-mutation callback (onSelfMutation) passed to AdminConsolePanel
captures savedOrigin via closure at render time. If the operator saves a
new origin B while Staffing's DELETE for origin A is still in flight, the
component re-renders and a new callback with savedOrigin=B is registered
in StaffingTab — but handleConfirmRemove, already suspended at the await,
still holds the old closure (savedOrigin=A). When DELETE resolves it calls
the old onSelfMutation, which now captures originAtRender=A and reads
savedOriginRef.current=B. The mismatch blocks the probe for A, leaving B's
authorized state intact.

Fix: add savedOriginRef (a useRef that mirrors savedOrigin state), updated
synchronously via setSavedOriginBoth on every setSavedOrigin call. The
onSelfMutation closure captures savedOrigin (state, stale OK) as
originAtRender, then checks savedOriginRef.current === originAtRender
before calling runProbe. A stale closure that outlives an origin change
is silently dropped rather than overwriting the new session's state.

Regression test: settings-card-stale-self-mutation-ignored-after-origin-switch
verifies probeCount stays at 2 after resolveDeleteA fires with savedOrigin=B.
Test is RED without the savedOriginRef fence (probeCount reaches 3).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Duncan and others added 2 commits September 21, 2026 15:16
… post-unmount probe leak

Without this fix, a deferred self-mutation completing after the Settings
session unmounts (identity teardown) passes the savedOriginRef fence
unchanged: savedOriginRef.current still holds the old origin A, so
originAtRender === savedOriginRef.current → true → runProbe(A) fires,
signing a NIP-98 request with the now-active identity's keys.

Fix: null savedOriginRef in the same unmount cleanup that already nulls
sessionTokenRef. The fence now sees null !== A → returns early, no probe.

StrictMode safe: the simulated cleanup nulls the ref, but the re-mount's
load effect calls setSavedOriginBoth which re-arms it for the live session.

Tests:
- New teardown regression: mounts full Settings→Panel→Staffing, starts
  self-removal, unmounts the session, resolves the deferred DELETE, and
  asserts no additional admin_probe IPC fires. RED without the fix
  (probeCount reaches 2), GREEN with it.
- Fold Thufir's MINOR on the origin-switch test: admin_probe mock now
  discriminates by origin (returns nip98Denied for stale A-origin calls
  after call 2), asserts probe origins are A then B, and asserts B's
  authorized panel is visible before A's DELETE resolves.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…n-surface

* origin/main:
  feat(relay): add admin HTTP routes for member restriction management (#7302)
  fix(relay): fire kick live side effects at convergence; persist target; fence re-add race with held lock (#7298)
  feat(relay): add atomic complete read-state snapshots (#7572)
  fix(desktop): register macOS badges for new and existing installs (#7783)
  fix(mobile): avoid opening empty threads on message tap (#7756)

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Alia <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@buzz.block.builderlab.xyz>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Review clear: origin/session callback blocker resolved

Reviewed head be0b4833045fc7f993c9e80c1f7ac7dea33c318c against exact base 77729abfb692b25a0f4ec4a69add86af2e32c0dd, focusing on the corrective delta since review 5269776708 and the intervening main merge. No remaining blocking defect found in that bounded scope.

  • Origin switch: the Settings callback checks its captured origin against the synchronously maintained current-origin ref before starting a probe. A delayed self-removal/demotion on A can no longer replace B’s authorization after B is saved. The Settings → Panel → Staffing regression authorizes B before settling A’s DELETE, then checks the probe count, targets, and surviving panel.
  • Session teardown: the existing unmount cleanup now clears that same ref. A retained mutation callback cannot start a fresh probe from the retired Settings session after an identity change. The added production-seam test unmounts before resolving DELETE and asserts no additional probe. Ordinary live-session self-mutation still refreshes principal state; parsed last-operator versus immutable-config 409 recovery messages remain intact.
  • Integration: the main merge has no manual resolution delta. The resulting relay router retains both the feature’s operator routes and main’s adjacent member-restriction routes. Previously reviewed report recovery, discovery/transport, and staffing implementations are unchanged where verified by blob identity. Same-host discovery, explicit Save for cross-host advertisements, manual internal origins, and separate relay/community authority remain the accepted contract.

Validation limits: immutable source/diff and test-source inspection on the pinned Blox host, with independent metadata review and coordinator verification. No checkout, build, test execution, or live reproduction. The teardown regression is not StrictMode-wrapped; a separate StrictMode Save test and the load-effect ordering cover re-arming, not a combined StrictMode/self-mutation runtime test. No speculative same-identity A→B→A finding was promoted into a blocker. Unrelated mainline changes and separate mobile/web operator UX were excluded.

CI is not certified green. The frozen exact-head snapshot at 2026-09-21 20:02:32Z showed 31 successful checks, 19 skipped, one failure, and four in progress. Desktop Domain / Desktop Smoke E2E (3) failed; its cause is unclassified here. No polling or reruns were performed. This source-review clearance does not waive CI or constitute approval.

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.

2 participants