Skip to content

feat(desktop): add in-app admin console for relay operators - #4768

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

feat(desktop): add in-app admin console for relay operators#4768
wpfleger96 wants to merge 7 commits into
mainfrom
wpfleger/desktop-admin-surface

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Aug 4, 2026

Copy link
Copy Markdown
Member

Summary

Adds a built-in admin console to the Buzz desktop app. Relay operators can view deployment-wide moderation reports and product feedback from Settings → Admin console — no browser extension, bearer token, or separate web UI required. Uses NIP-98 authentication (the app's own keypair).

This is the desktop counterpart to PR #3777 (relay /api/admin/v1 API).

What's in this PR

Rust (desktop/src-tauri)

New commands/admin/ module:

  • AdminOrigin value object — validates scheme://host[:port], rejects credentials/path/query/fragment; http:// only for loopback hosts
  • AdminRoute closed enum — five routes only (reports list, report detail, feedback list, feedback detail, feedback attachment); the webview cannot supply arbitrary URLs or paths; the signed URL is byte-identical to the fetched URL
  • Dedicated no-redirect reqwest client singleton — relay 3xx surfaced as error, Authorization header never forwarded across origins (redirect-hop SSRF guard)
  • 6 Tauri commands: admin_probe, admin_list_reports, admin_get_report, admin_list_feedback, admin_get_feedback, admin_fetch_feedback_attachment
  • 2 storage commands: get_admin_origin / set_admin_origin (per-pubkey JSON 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 kind-27235 event
  • Response bounds: 50 MiB JSON cap (200-row reports × 256 KiB note content), 64 KiB error cap, 10 MiB attachment cap — enforced by Content-Length preflight AND streaming byte counter
  • admin_fetch_feedback_attachment: caller supplies expected MIME/size from imeta-validated feedback detail; native layer validates Content-Type + byte count, returns body-only tauri::ipc::Response; stable typed error codes (admin_attachment_too_large, admin_attachment_mime_mismatch, …)
  • admin_probe: 6-state typed enum (Nip98Authorized / Nip98Denied / TokenMode / Disabled / NotAdminApi / NetworkOrIntercepted); Nip98Authorized only on a fully authenticated 2xx
  • Host-case pin test documents that url::Url lowercases ASCII hostnames (operators must configure BUZZ_ADMIN_HOST in lowercase)

TypeScript (desktop/src)

New features/admin-console/:

  • api.ts — typed wrappers for all 8 Tauri commands; attachment builds Blob from expectedMime (never a response header); caller revokes object URL
  • AdminConsoleSettingsCard.tsx — URL input, save/probe flow, per-pubkey state, honest copy for every probe state (denied shows copyable hex pubkey; token mode points at web console; network error names VPN/SSO interception)
  • AdminConsolePanel.tsx — tab bar (Reports / Feedback), list/detail views, AttachmentViewer with blob URL lifecycle and typed error messages

Updated features/settings/ui/SettingsPanels.tsx:

  • Adds "admin-console" to the SettingsSection union, SETTINGS_SECTION_VALUES array, settingsSections descriptor list (Server icon), and renderSettingsSection exhaustive switch

Docs

  • docs/admin/README.md — new Desktop app section (setup steps, probe state table, auth modes table, Cloudflare Access caveat verbatim from plan)
  • CHANGELOG.md — Unreleased entry

Ownership boundary

This PR touches only: desktop/src-tauri/src/** (new admin module + command registration), desktop/src/features/admin-console/**, SettingsPanels.tsx, docs/admin/README.md, CHANGELOG.md.

CHANGELOG.md and docs/admin/README.md overlap with PR #3777's diff by plan design — Phase 3 of the approved spec explicitly owns those two files. #3777 must merge first; this PR then rebases on top. The rebase is trivial: only those two files have textual conflicts; the Justfile auto-merges clean. No code changes are needed in either PR.

Track 2 (Moderator role, Duncan's ownership) has a disjoint boundary and no file overlap with this PR.

@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 2 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant