Skip to content

feat(desktop): role-based admin console — Phase 3 rework (v4 contract) - #4768

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

feat(desktop): role-based admin console — Phase 3 rework (v4 contract)#4768
wpfleger96 wants to merge 21 commits into
mainfrom
wpfleger/desktop-admin-surface

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Aug 4, 2026

Copy link
Copy Markdown
Member

Reworks the admin console in #4768 to match the Plan v4 role-based auth contract (frozen in the relay-admin-auth design thread).

What changed

api.ts

  • Added AdminPrincipalRole, AdminPrincipalSource, AdminOperatorDto, AdminReportAction, AdminReportDetailDto, AdminFeedbackStatus types
  • AdminProbeResult extended with role? and source? fields
  • Added resolveAdminReport, patchAdminFeedback, listAdminOperators, putAdminOperator, deleteAdminOperator, fetchAdminAttachmentBlobUrl API functions

AdminConsoleSettingsCard.tsx

  • Probe UI renders role badge + source badge when authorized
  • Passes role and source through to panel; read-only in token/disabled modes

AdminConsolePanel.tsx (refactored + split)

  • Role/source badges at panel top
  • Tab bar: Reports / Feedback / Staffing (Staffing gated on role === "operator")
  • Full frozen action matrix per target_kind: event → delete/kick/ban/timeout/dismiss/escalate; pubkey → ban/timeout/dismiss/escalate; blob → dismiss/escalate
  • EnforcementStateBlock: renders processing (not actionable), pending/enforcing/succeeded/failed with retry/cancel; cancel only offered pre-mutation (rejected cancel treated as authoritative)
  • ResolveReportForm: action buttons per matrix, timeout duration input (expiration_secs), reason field, client-generated request_id per attempt (reused on retry after lost response per v4 amendment 2)

AdminConsoleFeedbackTab.tsx (extracted)

  • Feedback list + detail + status control (new/reviewed/archived) via PATCH
  • AttachmentViewer: auto-loads image/* on mount, manual load for other MIME types, generation-fenced blob lifecycle

AdminConsoleStaffingTab.tsx (extracted)

  • Add-operator form (pubkey + role select)
  • Operator list with SourceBadge (config / owner_fallback / db)
  • Remove button disabled client-side for config-backed entries; 409 conflict surfaced as human-readable message

AdminConsolePanelHelpers.tsx (extracted)

  • Shared AsyncState type, useAsyncLoad hook, DetailRow, formatTimestamp, LoadingSpinner, ErrorMessage, AttachmentMeta type, parseImetaAttachments

src-tauri/src/commands/admin/

  • mod.rs: Tauri commands for all new API calls (admin_resolve_report, admin_patch_feedback, admin_list_operators, admin_put_operator, admin_delete_operator, admin_fetch_feedback_attachment)
  • helpers.rs (extracted): internal HTTP helpers; split to stay under the 1000-line ratchet

Tests

  • 12 jsdom tests, 4511 TS tests passing
  • New tests: role badge rendering, source badge rendering, moderator-sees-no-staffing-tab, operator-sees-staffing-tab, disabled-mode-no-staffing-tab, enforcement state block rendering, resolve form action matrix, feedback status control, staffing add/remove flows

Invariants enforced by the UI

  • processing reports are never presented as actionable
  • Cancel is only offered on pre-mutation failures; rejected cancel is treated as server-authoritative
  • Post-mutation delivery states render on a resolved report, never as enforcement failure
  • token/disabled modes: read-only, no action or staffing capabilities rendered
  • Config-backed operator entries: remove disabled client-side; 409 surfaced cleanly

Dependencies

Depends on Plan v4 Phases 1–2 relay implementation (buzz#3777) for runtime. The frozen v4 action contract (§7) is what this branch compiles against.

@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 2 times, most recently from 6daa9f1 to d5f9dd2 Compare August 4, 2026 23:29
npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw and others added 5 commits August 4, 2026 20:16
Add a NIP-98 client for the /api/admin/v1 relay API, surfaced as a new
'Admin console' section in Settings. Relay operators can view
deployment-wide moderation reports and product feedback from within the
Buzz desktop app — no browser extension or bearer token required.

Rust (Phase 1):
- AdminOrigin value object: validates scheme+host+optional-port, rejects
  credentials/path/query/fragment; http:// only for loopback hosts
- AdminRoute closed enum: five routes (reports list, report detail,
  feedback list, feedback detail, feedback attachment); no IPC surface
  accepts arbitrary URLs or paths; signed URL == fetched URL
- Dedicated no-redirect reqwest client singleton (SSRF guard: relay 3xx
  surfaced as error, NIP-98 header not forwarded across origins)
- Six Tauri commands: admin_probe, admin_list_reports, admin_get_report,
  admin_list_feedback, admin_get_feedback, admin_fetch_feedback_attachment
- Two storage commands: get_admin_origin / set_admin_origin (per-pubkey
  JSON file in app_data_dir, atomic write, 0o600)
- NIP-98 signing via AppState::signing_keys() — returns Err in recovery
  mode (locked keyring); exactly one retry on 401 with a fresh event
- Response bounds: 50 MiB JSON cap (200-row report list × 256 KiB notes),
  64 KiB error cap, 10 MiB attachment cap; enforced by Content-Length
  preflight AND streaming byte counter
- Attachment command: caller supplies expected MIME/size from imeta-
  validated feedback detail; native layer validates Content-Type and byte
  count, returns body-only tauri::ipc::Response; stable typed error codes
- admin_probe: 6-state typed enum (Nip98Authorized/Denied, TokenMode,
  Disabled, NotAdminApi, NetworkOrIntercepted); Nip98Authorized only on
  authenticated 2xx; never a Bearer fallback
- Host-case pin test documents that url::Url lowercases ASCII hostnames —
  operators must configure BUZZ_ADMIN_HOST in lowercase

TypeScript (Phase 2):
- desktop/src/features/admin-console/api.ts: typed wrappers for all
  8 Tauri commands; attachment returns Blob URL from expectedMime (never
  a response header); blob revocation on caller
- AdminConsoleSettingsCard: URL input field, save/probe flow, per-pubkey
  state, honest copy for every probe state (denied shows copyable hex
  pubkey, tokenMode points at web console, networkOrIntercepted names
  VPN/SSO interception)
- AdminConsolePanel: tab bar (Reports / Feedback), list/detail views,
  AttachmentViewer with blob URL lifecycle and typed error messages
- SettingsPanels: adds 'admin-console' section type, descriptor (Server
  icon), and render case; exhaustive switch maintained
- Probe state keyed by (active pubkey, canonical origin); in-flight
  probes cancelled on change; object URLs revoked on unmount

Docs (Phase 3):
- docs/admin/README.md: Desktop app section with setup steps, probe
  state table, and the Cloudflare Access caveat verbatim from the plan
- Authentication modes table added
- CHANGELOG.md: Unreleased entry

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Fix 1 (CRITICAL): Parse the real tags:string[][] imeta wire contract instead
of speculative aliases. Implements parseImetaAttachments() matching the
reference SPA — selects imeta tags, splits singleton key-value entries,
requires lowercase 64-hex x and positive size. Corrects camelCase field
names throughout (reportType, bodySummary, body, receivedAt).

Fix 2 (CRITICAL): Storage fail-closed. Both get/set_admin_origin now
propagate signing_keys()? instead of unwrap_or_default(), preventing
recovery-mode collapse onto a shared admin-console-origin-.json file.
Adds validate_pubkey_hex() guard requiring exactly 64 lowercase hex chars.

Fix 3 (IMPORTANT): Parse report/feedback IDs as uuid::Uuid before building
any route, making path injection via slash, .., ?, #, or percent-escapes
structurally impossible. AttachmentHash::parse() now enforces lowercase-hex
[0-9a-f]{64} — rejects uppercase (relay returns 404 on uppercase). Adds 11
adversarial tests.

Fix 4 (IMPORTANT): Harden admin_probe. Bounds every probe body read.
Validates the unauthenticated 200 response as a JSON array before returning
Disabled. Detects HTML/Cloudflare Access interception via
is_probe_response_intercepted() (checks final URL host and Content-Type).
Adds 8 live-listener async tests including HTML 200, malformed-JSON 200,
Nostr 401 → authenticated JSON 200 (stub validates Authorization header
shape), and persistent 401.

Fix 5 (IMPORTANT): Generation guards in TS. Replaces AbortController with
a generation counter keyed on (pubkey, origin). AdminConsolePanel takes a
required pubkey prop; generation increments on any (pubkey, origin) change.
useAsyncLoad captures generation at call time via a ref-based load pattern.
AttachmentViewer has its own per-load generation counter and checks
(origin, pubkey) before committing blob URLs. Blob URLs are revoked when
panelGeneration changes. AdminConsoleSettingsCard threads pubkey={pubkeyHex}.

Fix 6 (IMPORTANT): Revalidate persisted origin on read. get_admin_origin
now reparses the stored value through AdminOrigin::parse(), returns the
canonical form, and removes the file + returns an error if the stored value
is invalid or non-canonical.

Fix 7 (MINOR): Remove dead code — delete AdminFetchError enum and
admin_fetch_bytes_raw (never called by production paths). Fix clippy nit:
.map_or(false, |ip| ip.is_loopback()) → .is_ok_and(...). Fix doc default:
BUZZ_ADMIN_AUTH defaults to `token`, not `nip98`.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Fix 1 — Real generation fences (IMPORTANT):
- useAsyncLoad: replace closure-captured gen === generation tautology
  with effect-local 'let active = true' flipped false in cleanup.
  Stale completions check 'if (!active) return' before any setState.
- AttachmentViewer: increment loadGenRef in cleanup (before revoke) so
  unmount/panelGeneration change invalidates in-flight loads. Compare
  origin/pubkey against originRef/pubkeyRef (updated each render) so
  the check catches stale closure copies.
- AdminConsoleSettingsCard: synchronously abort+reset probe state and
  clear savedOrigin before any new load starts on pubkeyHex change.
  Input onChange also aborts/resets the active probe. getAdminOrigin()
  catch now sets ProbeUiState to error instead of silently ignoring.
- Add adminConsolePanel.test.mjs with 19 deferred-promise tests:
  active-flag semantics, old-list-after-new-list discard, identity
  switch discard, origin edit discard, load-gen cleanup invalidation,
  second-load supersedes first, and attachment unmount blob revoke.

Fix 2 — Surface persisted-origin failures (IMPORTANT):
- AdminConsoleSettingsCard: catch(e) on getAdminOrigin() sets
  ProbeUiState to { kind: 'error', message } instead of ignoring.
- mod.rs: both remove_file calls in get_admin_origin include the
  removal failure in the returned error string.

Fix 3 — Probe shape + redirect on retry (IMPORTANT):
- looks_like_admin_list: requires application/json Content-Type AND
  validates non-empty array elements have an 'id' field. Empty array
  still valid. Rejects [1], ['garbage'], [{notId:true}].
- admin_probe refactored: admin_probe_inner(url, sign_fn) with
  injectable signing closure. Tauri command wraps it. is_redirection()
  added on authenticated retry path (was missing — Nostr 401->302
  became Nip98Denied instead of NetworkOrIntercepted).
- Extract SignFn type alias to satisfy clippy type_complexity lint.
- Tests moved to mod_tests.rs (keeps mod.rs under 1000-line ratchet).
- 35 Rust admin tests including full state-machine tests via live TCP:
  HTML 200, malformed-JSON 200, bare-array-of-garbage 200, empty
  array 200, persistent 401, Nostr 401->JSON 200 (records Auth header),
  authenticated 302 -> NetworkOrIntercepted, bearer 401, recovery mode.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
1. Identity session boundary: extract AdminConsoleSettingsSession as a
   keyed inner component (key={pubkeyHex}), so React unmounts A's entire
   state tree synchronously before B renders and logout tears down all
   probe state. sessionTokenRef guards handleSave completions — delayed
   saves from old sessions cannot repopulate the new session. Both native
   storage commands accept expected_pubkey and reject mismatches.
   abortAndResetProbe is the general reset on every input change.

2. Storage tests through production code: extracted get_admin_origin_core
   and set_admin_origin_core (parameterised by data dir + pubkey hex, no
   tauri::State). Tauri commands are thin adapters. Real-file tests cover
   write/read round trip, two-identity isolation, malformed-JSON quarantine,
   forbidden-origin quarantine, clear, and absent-file cases.

3. Header-asserting stub + real list contract: serve_sequence_inspect
   inspects raw HTTP request bytes. The NIP-98 challenge test asserts the
   second request's Authorization header equals the signing closure's token
   at the transport layer — deleting the production .header(AUTHORIZATION,
   ...) call fails the test with Nip98Denied. looks_like_admin_list
   deserialises every non-empty element against AdminReportProbeDto
   (camelCase UUID fields); the pinned garbage fixture is rejected.

   Also fixes clippy type_complexity (RequestInspector type alias) and
   Biome lint issues (unused imports, template literals, exhaustive-deps
   suppression on intentional mount-once effect).

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Three items from Paul's round-3 dispatch were missing or incorrect:

origin-edit test: now dispatches a real DOM input-change event while a
deferred probe is in-flight (not re-probe button clicks). The test asserts
that the stale probe result (nip98Authorized) is discarded after onChange
calls abortAndResetProbe(). The delayed-save discard assertion is now
unconditional — the previous 'if (inputB)' guard made it vacuously passable.

Three missing panel-level tests added:
- old-list-after-new-list: mounts AdminConsolePanel with pubkeyA/originA,
  defers the list IPC, re-renders with pubkeyB/originB, resolves A's stale
  list, asserts it does not appear; resolves B's live list, asserts it does.
  Proves useAsyncLoad's active-flag cleanup.
- detail-navigation: list resolves immediately; user clicks a report button
  to start a detail fetch (deferred); origin/pubkey switches to bump
  generation; stale detail resolves and must not appear. Proves active flag
  on detail's useAsyncLoad effect.
- attachment-unmount: feedback list + detail resolve immediately; tab
  switched to Feedback; attachment fetch deferred; panelGeneration bumped
  via origin/pubkey change (triggers cleanup that increments loadGenRef);
  stale attachment resolves; asserts no img element with stale blob URL.
  Proves AttachmentViewer's loadGenRef cleanup.

NIP-98 stub test: RequestRecord struct captures method, path, and auth for
each request. Assertions verify: request 0 is GET to reports path with no
Authorization header; request 1 is GET to reports path with Authorization
header equal to the signing closure token. Comments describe the actual
mechanism — deleting the production .header(AUTHORIZATION, ...) call causes
request 1 to arrive with no header, the post-hoc equality assertion fails.
probe_inner_missing_auth_header_fails_to_authorize comment updated to
accurately describe the no-sign path.

All gates: clippy, typecheck, fmt-check green; 4305 TS tests passed;
2286 Rust tests passed.

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 d5f9dd2 to a47fafe Compare August 5, 2026 00:19
npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw and others added 7 commits August 4, 2026 22:35
…stub

Replace the hand-rolled MinimalEventTarget shim with jsdom pre-installed
via --import test-jsdom-setup.mjs so React 19's isInputEventSupported=true
and container-level event delegation works. Split test files:

- adminConsolePanel.test.mjs: prop/query-driven tests (no event dispatch needed)
- adminConsolePanelEvents.jsdom-test.mjs: RTL+jsdom event-driven tests

Event-driven tests (origin-edit, same-session-save-race, detail-navigation,
attachment-unmount) now use fireEvent.change/click which reach production
handlers. Each is mutation-verified: removing the targeted line causes the
test to fail.

Also: rewrite serve_gated_nip98 so slot-1 returns 200 only when the received
Authorization header matches the signing closure token (mismatch -> 401);
removing .header(AUTHORIZATION, ...) from the probe retry returns Nip98Denied,
failing the Nip98Authorized assertion. Remove the false comments claiming the
previous post-hoc pattern was a gate.

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

Add blob-leak-on-back-navigation test: user clicks 'Back to feedback' while
an attachment fetch is in-flight, unmounting AttachmentViewer without changing
origin or pubkey. Only loadGenRef.current += 1 in cleanup prevents the stale
blob URL from committing — the origin/pubkey ref checks are equal (no context
change), so removing the cleanup increment makes revokedUrls stay empty and
the test fails.

Also fix the three silent skip paths: convert the if(!x){…return} guards in
detail-navigation and attachment-unmount to assert.ok(x, '…') so a DOM query
miss is a hard test failure rather than a silent no-op.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…oss-identity save tests

Full-typed AdminReportProbeDto and two new rejection fixtures:

- Replace createdAt: serde_json::Value with chrono::DateTime<Utc>; replace
  channel_id: missing with Option<uuid::Uuid>. Both match the wire types in
  crates/buzz-db/src/admin_moderation.rs exactly.
- Add looks_like_admin_list_rejects_created_at_null: full-shape element with
  createdAt: null must not classify as admin API.
- Add looks_like_admin_list_rejects_malformed_optional_field: full-shape element
  with channelId: 7 (not a UUID) must not classify as admin API.

authorized-logout-teardown test (MinimalDocument suite):

- flushSync(render) + flushSync(unmount) between renders and assertion. The
  synchronous unmount before passive effects prevents React's scheduler from
  keeping Node's event loop alive, which would otherwise cause a CANCELLED
  result instead of a clean AssertionError when the mutation is applied.
- Removed the jsdom version: React 19's global act() scheduler combined with
  orphaned roots from prior tests causes CANCELLED instead of FAILED when the
  render gate mutation is applied in the jsdom environment.

cross-identity-delayed-save jsdom test:

- Authorize A, edit input, start deferred set_admin_origin, switch to B, resolve
  A's save late. Asserts (a) the IPC call carried expectedPubkey = pubkeyA,
  (b) B's input is empty, (c) B's panel is not authorized.
- Delete prop-only logout and delayed-save-after-switch duplicates from
  adminConsolePanel.test.mjs per minimalism finding (duplicated identity-switch).

makeQueryClient fix in both suites: always seed { pubkey: pubkeyHex } with
staleTime: Infinity so React Query never calls the unmocked getIdentity IPC.

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

Rewrite authorized-logout-teardown to exercise the full A→logout transition
(mount with pubkeyA authorized, assert panel present, transition to pubkeyHex="",
assert both input and panel gone) using the same act+qc.setQueryData+settle
pattern as identity-switch. The previous flushSync implementation mounted with
an empty pubkey from the start and never tested the transition Thufir flagged.

Add assertion (d) to cross-identity-delayed-save: record admin_probe invocations
after the identity switch, resolve A's deferred save late, assert no probe carries
A's origin. This required a production fix: AdminConsoleSettingsSession was calling
runProbe() from handleSave's continuation after unmount because sessionTokenRef
(designed for same-session concurrent saves) still matched A's own token after
the key change. Added isMountedRef (set false in useEffect cleanup) and gated
runProbe() on !isMountedRef.current to prevent the stale IPC call.

Mutation evidence (production restored after each):
- Remove pubkeyHex gate: authorized-logout-teardown RED
  AssertionError: admin origin input must not render when pubkeyHex is empty — render gate missing
- Drop expectedPubkey from set_admin_origin: cross-identity-delayed-save RED
  AssertionError: set_admin_origin must carry expectedPubkey = pubkeyA; got: {"rawOrigin":"..."}

Gates at this head:
- just desktop-tauri-clippy: pass
- just desktop-typecheck: pass
- just desktop-check: pass (2 pre-existing Biome warnings, unchanged)
- just desktop-test: 4301 regular + 6 jsdom passed, 0 failed
- just desktop-tauri-test: 2288 Rust tests passed, 13 ignored, 0 failed

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…TokenRef null fence

The isMountedRef approach (cleanup sets isMountedRef.current = false, no
reset in effect body) is broken under React.StrictMode: double-invoked
effects set the ref permanently false, silently killing every handleSave
completion in dev builds — origin save dead in all dev environments.

Replace with a sessionTokenRef null cleanup:
  useEffect(() => () => { sessionTokenRef.current = null; }, [])

On unmount, sessionTokenRef.current = null. Every handleSave continuation
leg already checks sessionTokenRef.current !== token (null !== object) so
all legs return early uniformly — including clear-origin, catch, and finally
paths that the isMountedRef gate left uncovered. StrictMode-safe: the
simulated cleanup nulls the ref, then re-arm happens at the next
handleSave's sessionTokenRef.current = token assignment.

Pin the StrictMode defect with a jsdom test: mount AdminConsoleSettingsCard
wrapped in React.StrictMode with gcTime: Infinity (prevents identity query
GC during double-mount), edit input, press Enter, assert probe fires for
the canonical origin. Without the fix: probes: [].

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The PR registered the admin-console section in SettingsPanels.tsx but
never added it to settingsNavGroups in SettingsView.tsx. The sidebar
renders from settingsNavGroups exclusively — any section absent from
that list is silently filtered out of visibleNavGroups and has no
reachable entry point.

Add "admin-console" as the last entry in the App group, and export
settingsNavGroups so the pinning test can assert directly on the
production value without re-implementing the logic.

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

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 42e67e3 to 9bb1666 Compare August 6, 2026 17:49
Hayt and others added 8 commits August 6, 2026 15:29
…, copy button for denied pubkey, image auto-load

Item 1: rename isAuthorized to isPanelVisible; include probeUiState.kind === 'disabled'
in the gate condition so the admin panel mounts in auth-disabled relay mode. Update
the stale header comment in both AdminConsoleSettingsCard and AdminConsolePanel.

Item 2: replace the cursor-pointer/select-all <code> block in the denied badge with
DeniedBadge component using Copy/Check lucide-react icons and copyTextToClipboard,
matching the PubKey.tsx CopyRow pattern.

Item 3: replace JSON.stringify <pre> blocks in ReportDetail and FeedbackDetail with
ReportFields and FeedbackFields components -- grid-cols label/value rows, Badge for
status/reportType, formatTimestamp for created/updated/received fields, '--' for
null/absent values.

Item 4: add useEffect in AttachmentViewer that auto-loads image/* attachments on
mount via the existing load() callback. Non-image MIME types keep the 'View
attachment' button. Routes through the same generation fence and blob-lifecycle
machinery.

Fold-in: fix swapped mutation-narration comments in adminConsolePanelEvents.jsdom-test.mjs
strict-mode-save test.

Tests: 6 new pinning tests in adminConsolePanel.test.mjs; 2 new jsdom tests
(report/feedback structured fields) in adminConsolePanelEvents.jsdom-test.mjs;
attachment-unmount test updated to use application/pdf (non-image) to preserve
the original load-gate scenario; blob-leak-on-back-navigation test updated to
navigate to detail without needing a 'View attachment' click.

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

Move the 86-line inline PTT global-shortcut plugin closure from lib.rs
into ptt_shortcut::build_plugin(). lib.rs was 1005 lines, over the 1000-
line file-size ratchet; it is now 918 lines with headroom.

No behavior change — same generation-fence debounce logic, same 200 ms
release delay, same cfg(not(test)) omission gate.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* origin/main:
  Alert community owners and admins when a new key joins (#4900)
  fix(desktop): prevent sidebar prefs from reverting on stale-localStorage boot (#5086)
  chore(hooks): run desktop typecheck in pre-push (#5110)
  feat(identity): recover desktop identity from a signed-in phone (#4845)

Signed-off-by: Hayt <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…elative timestamps

Replace the Record<string, unknown> casts and invented field names in
ReportFields/FeedbackFields with typed AdminReportDetailDto/AdminFeedbackDto
that mirror the Rust serde wire contract field-for-field.

Changes:
- api.ts: add AdminReportDto, AdminReportedMessageDto, AdminReportDetailDto,
  AdminFeedbackDto; update data command return types from unknown to these types
- AdminConsolePanel.tsx: import DTO types; replace formatTimestamp's absolute
  locale output with formatRelativeTime (ISO → unix seconds → relative label
  with absolute tooltip); fix ReportFields to drop five invented rows (reason,
  moderatorPubkey, moderationAction, moderationNote, updatedAt) and add five
  real rows (channelId, note, resolvedBy, resolvedAt, actionId) plus a nested
  reported-message block (authorPubkey, content, createdAt, deleted indicator);
  fix FeedbackFields to drop appVersion/platform/authorPubkey and add
  submitterPubkey, category, communityId, communityHost, eventId, body,
  eventCreatedAt, receivedAt; remove all Record<string,unknown> casts
- AdminConsoleSettingsCard.tsx: fix header comment — disabled means relay
  does not require/validate a credential; desktop still signs
- adminConsolePanelEvents.jsdom-test.mjs: replace invented-key fixtures with
  full contract-accurate AdminFeedbackDto/AdminReportDetailDto shapes; add
  assertions for note, resolvedBy, nested message content, submitterPubkey,
  category; add three new tests: nullable-graceful-degradation (no message
  block when null), mutation-evidence-resolvedBy (wrong key → invisible),
  mutation-evidence-nested-message (block removed → invisible)

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

The relay's GET /admin/feedback handler returns a FeedbackSummary shape
(bodySummary, no body/eventId/tags/eventCreatedAt) distinct from the full
AdminFeedback detail. The previous fix typed listAdminFeedback() as
AdminFeedbackDto[] and rendered item.body, causing blank row titles against
a real relay.

Changes:
- api.ts: add AdminFeedbackSummaryDto (7-field FeedbackSummary mirror,
  sourced from buzz-relay/src/api/admin/mod.rs); listAdminFeedback() now
  returns Promise<AdminFeedbackSummaryDto[]>
- AdminConsolePanel.tsx: FeedbackTab iterates AdminFeedbackSummaryDto and
  renders item.bodySummary; fix line-130 comment (absolute renders inline
  in parens, not as a tooltip)
- adminConsolePanelEvents.jsdom-test.mjs:
  - Split all feedback list/detail fixtures: summary fixture has bodySummary
    only; detail fixture has body + eventId + tags + eventCreatedAt
  - feedback-detail-renders-structured-fields: add pre-navigation assertion
    that list row shows bodySummary (mutation seam: body→bodySummary swap)
  - Add relative-timestamp format assertion (/\d+[mhd] ago \(/)
  - Fix all targetKind values from 'message' to 'event' (relay contract)
  - contract-dto-mutation-evidence-nested-message: set deletedAt to a
    non-null timestamp; assert (deleted) indicator renders (mutation seam:
    remove deletedAt branch → red)

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
## Summary

- mirror the retained canvas terminal grid into a transparent,
selectable text layer
- preserve the canvas renderer and terminal focus behavior for ordinary
clicks
- reconstruct wide and combining glyphs correctly for clipboard text

## Why

Buzz Term renders output entirely on a canvas and deliberately called
`preventDefault()` on viewport mouse-down, so native selection and copy
could not work. A canvas has no selectable text even if that
cancellation is removed.

The transparent text layer stays aligned with the visible cell grid,
lets WebView native selection drive drag highlighting and copy, and
follows active-session switches without changing the renderer or PTY
protocol.

## Validation

- `pnpm --dir desktop typecheck`
- `pnpm --dir desktop test` — 4,373 passed
- pre-push `desktop-check`, `desktop-test`, and `branch-skew` hooks
passed on `1f2a3f8db63f6fe36b4a28bc911aea3c5186b2b0`

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
**Category:** fix
**User Impact:** Agent cards and catalog listings now show the avatar
belonging to the identity they represent.

**Problem:** Running agent cards could show a stale definition avatar
instead of the concrete agent profile, while adding another publisher's
catalog entry could let local edits repaint that publisher's listing.
This made agent identity look inconsistent across My Agents and the
Agent Catalog.

**Solution:** Treat the concrete agent pubkey profile as authoritative
for running-card avatars, with the linked definition as fallback. Keep
relay publications authoritative for foreign catalog presentation while
using local copies only for linkage and selection state.

| before | after |
|--|--|
| <img width="874" height="592" alt="Screenshot 2026-08-06 at 3 48
43 PM"
src="https://github.com/user-attachments/assets/2cc6c9f7-ea50-413c-9c7b-4d34bd8b4ec7"
/> | <img width="884" height="597" alt="Screenshot 2026-08-06 at 3 48
40 PM"
src="https://github.com/user-attachments/assets/b14a865c-65c4-458f-9c30-d1a557c877d7"
/> |
| agent-set avatar not showing | agent-set avatar is showing |

## Changes

<details>
<summary>File changes</summary>

**desktop/src/features/agents/lib/agentCardAvatar.ts**
Adds the explicit avatar precedence rule for running agent cards and
blocks avatar-dependent actions until the authoritative profile query
settles.

**desktop/src/features/agents/lib/agentCardAvatar.test.mjs**
Covers profile precedence, definition fallback, blank avatar handling,
and the profile-loading transition for linked-agent actions.

**desktop/src/features/agents/lib/personaCatalogRelay.ts**
Keeps publisher-provided catalog identity and behavior fields
authoritative after a local copy is added.

**desktop/src/features/agents/lib/personaCatalogRelay.test.mjs**
Verifies local copies contribute linkage and selection without
overriding publisher presentation.

**desktop/src/features/agents/ui/UnifiedAgentsSection.tsx**
Uses the concrete agent profile avatar before the linked definition
avatar on running-agent cards.

</details>

## Reproduction Steps

### Running agent card uses the agent profile avatar

Use two visibly different, publicly reachable image URLs: **A** for the
saved definition and **B** for the running agent profile.

1. In **Settings → Experiments**, enable **Agent-managed profiles**.
This prevents Desktop from restoring the definition avatar over an
agent's own relay-profile changes.
2. In **Agents**, create an agent with image **A** as its avatar and
start it.
3. In a channel containing that agent, ask it to update its own Buzz
profile avatar to image **B**. The exact CLI operation under the agent
identity is `buzz users set-profile --avatar <image-B-url>`.
4. After the agent confirms the update, reopen **Agents → My Agents**
(or reload the page so its kind:0 profile is fetched again).
5. Verify the running agent card shows image **B**, not definition image
**A**. Open **⋯ → Share** and verify the share flow also uses image
**B**.

Before this fix, the My Agents card and share flow preferred image **A**
whenever the linked definition had an avatar.

### Catalog listing remains publisher-authoritative

This scenario requires a second Buzz identity so the entry is foreign to
the account under test.

1. As the publisher identity, create an agent definition with a
distinctive name, avatar, and instructions, then use **Share → Share to
catalog**.
2. As the test identity, open **Agents → Discover agents**, find that
publication, and add it.
3. In **My Agents**, open the added copy's **⋯ → Edit**, change its
name, avatar, and instructions, and save.
4. Return to **Discover agents** and find the same publisher entry.
5. Verify it remains selected/added but still shows the publisher's
original name, avatar, and instructions—not the test identity's local
edits.

## Validation

- `pnpm test` — 4,376 passed
- `pnpm typecheck` — passed
- `pnpm check` — passed with existing non-error notices

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…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
…n/README to main

CHANGELOG.md had a stale merge-conflict closing marker from the
original rebase. docs/admin/README.md is relay-auth documentation that
belongs with the relay PR (buzz#3777); reverted to origin/main state so
the correct principal-model docs land in the relay branch.

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.

3 participants