feat(desktop): role-based admin console — Phase 3 rework (v4 contract) - #4768
Open
wpfleger96 wants to merge 21 commits into
Open
feat(desktop): role-based admin console — Phase 3 rework (v4 contract)#4768wpfleger96 wants to merge 21 commits into
wpfleger96 wants to merge 21 commits into
Conversation
wpfleger96
force-pushed
the
wpfleger/desktop-admin-surface
branch
2 times, most recently
from
August 4, 2026 23:29
6daa9f1 to
d5f9dd2
Compare
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
force-pushed
the
wpfleger/desktop-admin-surface
branch
from
August 5, 2026 00:19
d5f9dd2 to
a47fafe
Compare
…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
force-pushed
the
wpfleger/desktop-admin-surface
branch
from
August 6, 2026 17:49
42e67e3 to
9bb1666
Compare
…, 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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.tsAdminPrincipalRole,AdminPrincipalSource,AdminOperatorDto,AdminReportAction,AdminReportDetailDto,AdminFeedbackStatustypesAdminProbeResultextended withrole?andsource?fieldsresolveAdminReport,patchAdminFeedback,listAdminOperators,putAdminOperator,deleteAdminOperator,fetchAdminAttachmentBlobUrlAPI functionsAdminConsoleSettingsCard.tsxroleandsourcethrough to panel; read-only intoken/disabledmodesAdminConsolePanel.tsx(refactored + split)role === "operator")EnforcementStateBlock: rendersprocessing(not actionable),pending/enforcing/succeeded/failedwith 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-generatedrequest_idper attempt (reused on retry after lost response per v4 amendment 2)AdminConsoleFeedbackTab.tsx(extracted)new/reviewed/archived) viaPATCHAttachmentViewer: auto-loadsimage/*on mount, manual load for other MIME types, generation-fenced blob lifecycleAdminConsoleStaffingTab.tsx(extracted)SourceBadge(config/owner_fallback/db)AdminConsolePanelHelpers.tsx(extracted)AsyncStatetype,useAsyncLoadhook,DetailRow,formatTimestamp,LoadingSpinner,ErrorMessage,AttachmentMetatype,parseImetaAttachmentssrc-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 ratchetTests
Invariants enforced by the UI
processingreports are never presented as actionableresolvedreport, never as enforcement failuretoken/disabledmodes: read-only, no action or staffing capabilities renderedDependencies
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.