Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
efd15bb
feat(desktop): relay admin console for the /api/admin/v1 operator sur…
Sep 3, 2026
6a4b6b7
feat(admin-console): implement Will's staging UX feedback
Sep 3, 2026
b0b5d64
fix(admin-console): use shared useUsersBatchQuery in StaffingTab; sco…
Sep 3, 2026
447b9e7
fix(desktop): limit report-error prefix strip to HTTP status codes
wpfleger96 Sep 8, 2026
d0e5070
refactor(desktop): retain parsed admin origin components and propagat…
wpfleger96 Sep 8, 2026
ad6b7f5
refactor(desktop): remove dead admin nav-gating module
wpfleger96 Sep 8, 2026
56f6599
perf(desktop): gate members-sidebar expiry tick on observable timeout
wpfleger96 Sep 8, 2026
97beecd
perf(desktop): scope composer timeout re-renders to the banner
wpfleger96 Sep 8, 2026
e5c5812
refactor(desktop): extract AdminConsoleReportsTab from panel
wpfleger96 Sep 8, 2026
568753a
docs(desktop): document 422 resolve retry policy; memoize community g…
wpfleger96 Sep 8, 2026
ede686a
fix(buzz-relay): return effective operator entry from PUT /operators/…
wpfleger96 Sep 8, 2026
5f0925f
fix(desktop): classify staffing operator 409s via typed relay status
wpfleger96 Sep 8, 2026
acd5691
fix(buzz-relay): compare admin Host case-insensitively
wpfleger96 Sep 8, 2026
bae7729
feat(desktop): surface relay status on operator delete
wpfleger96 Sep 8, 2026
98b49c0
fix(desktop): bind admin origin auto-probe to the connected relay host
wpfleger96 Sep 8, 2026
efa68d2
Merge remote-tracking branch 'origin/main' into wpfleger/desktop-admi…
wpfleger96 Sep 8, 2026
1d3ea98
Merge remote-tracking branch 'origin/main' into wpfleger/desktop-admi…
Sep 21, 2026
0ab0043
fix(desktop): refresh probe on self-mutation; surface relay 409 messa…
Sep 21, 2026
78da302
test(admin-console): add SettingsCard→panel onSelfMutation wiring tests
Sep 21, 2026
6dcc6a1
fix(admin-console): fence stale self-mutation probe after origin switch
Sep 21, 2026
fc1148a
fix(admin-console): clear savedOriginRef on session teardown to close…
Sep 21, 2026
a032162
Merge remote-tracking branch 'origin/main' into wpfleger/desktop-admi…
Sep 21, 2026
be0b483
test(admin-console): correct test comment claims
Sep 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 55 additions & 7 deletions crates/buzz-relay/src/api/admin/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,23 @@ pub(crate) fn admin_source_str(source: &AdminSource) -> &'static str {
}
}

/// Compare an inbound Host against the configured admin host case-insensitively.
/// Host names are case-insensitive (RFC 3986 §6.2.2.1), and `config.host` is
/// already lowercased at config load — but a proxy, curl, or non-desktop client
/// can still send a mixed-case Host header, so the comparison itself must fold
/// case rather than relying on the inbound value already being lowercase.
fn host_matches(inbound: &str, configured: &str) -> bool {
inbound.eq_ignore_ascii_case(configured)
}

pub(crate) fn is_admin_host(state: &AppState, headers: &HeaderMap) -> bool {
let Some(config) = state.config.admin.as_ref() else {
return false;
};
headers
.get(header::HOST)
.and_then(|value| value.to_str().ok())
.is_some_and(|host| host == config.host)
.is_some_and(|host| host_matches(host, &config.host))
}

/// Scheme for an admin authority: `http://` for loopback hosts (`localhost`,
Expand Down Expand Up @@ -443,19 +452,47 @@ fn nostr_credential(value: &str) -> Option<&str> {
}

fn origin_matches_host(origin: &str, host: &str) -> bool {
// Compare against the exact canonical origin: https:// for non-loopback,
// http:// for loopback. Accepting either scheme for non-loopback would
// allow plaintext origins for production hosts.
let expected = format!("{}://{host}", scheme_for_host(host));
origin == expected
// The scheme is matched exactly — https:// for non-loopback, http:// for
// loopback. Accepting either scheme for non-loopback would allow plaintext
// origins for production hosts, so the scheme check must not fold anything.
// The host portion, by contrast, is case-insensitive (RFC 3986 §6.2.2.1)
// and may arrive mixed-case from a browser, so it folds case.
let Some(origin_host) = origin
.strip_prefix(scheme_for_host(host))
.and_then(|rest| rest.strip_prefix("://"))
else {
return false;
};
origin_host.eq_ignore_ascii_case(host)
}

#[cfg(test)]
mod tests {
use super::{
admin_api_origin, canonical_url, method_has_body, nostr_credential, origin_matches_host,
admin_api_origin, canonical_url, host_matches, method_has_body, nostr_credential,
origin_matches_host,
};

#[test]
fn admin_host_compare_is_case_insensitive() {
// config.host is lowercased at load, but a proxy/curl/non-desktop
// client can still send a mixed-case Host header — it must match.
assert!(host_matches(
"Admin.Example.Com:8443",
"admin.example.com:8443"
));
// Exact same-case is trivially a match.
assert!(host_matches(
"admin.example.com:8443",
"admin.example.com:8443"
));
// A genuinely different host never matches.
assert!(!host_matches(
"attacker.example:8443",
"admin.example.com:8443"
));
}

#[test]
fn browser_origin_must_match_admin_host() {
assert!(origin_matches_host(
Expand Down Expand Up @@ -492,6 +529,17 @@ mod tests {
"https://admin.localhost:3000",
"admin.localhost:3000"
));
// Host is case-insensitive (RFC 3986 §6.2.2.1): a mixed-case Origin
// host matches the lowercased configured host, but the scheme is still
// matched exactly (http rejected for a non-loopback host).
assert!(origin_matches_host(
"https://Admin.Example.Com",
"admin.example.com"
));
assert!(!origin_matches_host(
"http://Admin.Example.Com",
"admin.example.com"
));
}

#[test]
Expand Down
146 changes: 140 additions & 6 deletions crates/buzz-relay/src/api/admin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use std::sync::Arc;

use auth::{
admin_role_str, admin_source_str, authorize, require_mutation_principal, require_operator,
AdminRole,
resolve_admin_principal, AdminRole,
};
use axum::{
body::Bytes,
Expand Down Expand Up @@ -208,7 +208,7 @@ async fn reports(
.await?;
validate(
query.status.as_deref(),
&["open", "resolved", "dismissed", "escalated"],
REPORT_STATUS_ALLOWLIST,
"invalid_status",
)?;
validate(query.scope.as_deref(), &["all"], "invalid_scope")?;
Expand Down Expand Up @@ -436,6 +436,11 @@ struct ResolveReportBody {
/// chrono/`i64` overflow range so the computation can never panic.
const MAX_TIMEOUT_SECS: u64 = 365 * 24 * 60 * 60;

/// Allowed explicit `status=` values for the `list_reports` endpoint.
/// Mutation: remove "processing" here → `report_status_accepts_processing` goes RED.
const REPORT_STATUS_ALLOWLIST: &[&str] =
&["open", "processing", "resolved", "dismissed", "escalated"];

/// Convert an attacker-controlled `expiration_secs` into a future timeout
/// instant, rejecting zero, the over-cap range, and any value that would
/// overflow the timestamp arithmetic. Never panics; never yields a past instant.
Expand Down Expand Up @@ -992,7 +997,7 @@ async fn upsert_operator(
headers: HeaderMap,
Path(pubkey_hex): Path<String>,
body_bytes: Bytes,
) -> Result<Json<serde_json::Value>, ApiError> {
) -> Result<Json<OperatorEntry>, ApiError> {
let principal_opt = authorize(
&state,
&headers,
Expand Down Expand Up @@ -1048,9 +1053,22 @@ async fn upsert_operator(
_ => ApiError::internal(),
})?;

Ok(Json(
serde_json::json!({"pubkey": canonical_hex, "role": body.role}),
))
// Return the effective principal so the response body matches the shape
// `list_operators` returns (and the desktop `AdminOperatorDto` type). Re-resolve
// through the shared config+DB path rather than constructing the entry inline:
// the 409 guard above excludes config-backed keys, so this resolves to the
// freshly written DB grant (`sources == ["db"]`), and re-resolving keeps the
// contract honest if that guard assumption ever shifts.
let target: [u8; 32] = target_bytes
.as_slice()
.try_into()
.map_err(|_| ApiError::internal())?;
let resolved = resolve_admin_principal(&state, target).await?;
Ok(Json(OperatorEntry {
pubkey: canonical_hex,
effective_role: admin_role_str(resolved.role).to_string(),
sources: vec![admin_source_str(&resolved.source).to_string()],
}))
}

/// DELETE /operators/{pubkey}
Expand Down Expand Up @@ -1807,6 +1825,33 @@ mod postgres_tests {
assert!(validate(Some("unknown"), &["open"], "invalid_status").is_err());
}

#[test]
fn report_status_accepts_processing() {
// Wes P2 round-6: explicit status=processing must be accepted by the
// allowlist used in list_reports. References the production constant so
// removing "processing" from REPORT_STATUS_ALLOWLIST makes this RED
// while the omitted-default and scope=all tests stay green.
assert!(
validate(
Some("processing"),
REPORT_STATUS_ALLOWLIST,
"invalid_status"
)
.is_ok(),
"status=processing must be in the production allowlist"
);
// Confirm the gate still rejects values outside the set.
assert!(
validate(
Some("unknown_state"),
REPORT_STATUS_ALLOWLIST,
"invalid_status"
)
.is_err(),
"status=unknown_state must be rejected by the production allowlist"
);
}

#[test]
fn feedback_summary_is_unicode_safe_and_marks_truncation() {
let body = "🐝".repeat(241);
Expand Down Expand Up @@ -5369,6 +5414,95 @@ mod postgres_tests {
assert_eq!(remaining, 0, "the canonical row must be removed");
}

/// Contract seam: PUT /operators/{pubkey} must return the effective
/// `OperatorEntry` (camelCase `effectiveRole` + `sources`), not a bare
/// `{pubkey, role}` — the desktop types the result as `AdminOperatorDto`.
/// Exercises the real HTTP handler so a regression to inline `json!` would
/// drop `effectiveRole`/`sources` and fail here. The uppercase-path PUT pins
/// that the echoed pubkey is canonicalized to lowercase.
#[tokio::test]
#[ignore = "requires Postgres — PUT /operators returns the effective OperatorEntry"]
async fn upsert_operator_returns_effective_operator_entry() {
let operator_keys = nostr::Keys::generate();
let state = nip98_state(vec![operator_keys.public_key().to_hex()]).await;

let target_keys = nostr::Keys::generate();
let lower_hex = target_keys.public_key().to_hex();

// PUT a moderator grant on a fresh, non-config key.
let path = format!("/operators/{lower_hex}");
let put_body = r#"{"role":"moderator"}"#.as_bytes();
let put = status_for(
state.clone(),
Request::builder()
.method("PUT")
.uri(&path)
.header(header::HOST, "admin.example")
.header(
header::AUTHORIZATION,
make_nostr_auth_put(&operator_keys, &path, put_body),
)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(put_body.to_vec()))
.expect("request"),
)
.await;
assert_eq!(put.status(), StatusCode::OK, "grant PUT must succeed");
let put_json: serde_json::Value = {
let bytes = axum::body::to_bytes(put.into_body(), 4096)
.await
.expect("body");
serde_json::from_slice(&bytes).expect("json")
};
assert_eq!(
put_json["pubkey"], lower_hex,
"response echoes the canonical lowercase pubkey"
);
assert_eq!(
put_json["effectiveRole"], "moderator",
"response carries the effective role"
);
assert_eq!(
put_json["sources"],
serde_json::json!(["db"]),
"a non-config grant resolves to the db source only"
);

// Idempotent re-PUT through an uppercase path: the echoed pubkey must
// still be lowercased even though the path param is uppercase.
let upper_path = format!("/operators/{}", lower_hex.to_ascii_uppercase());
let upper = status_for(
state,
Request::builder()
.method("PUT")
.uri(&upper_path)
.header(header::HOST, "admin.example")
.header(
header::AUTHORIZATION,
make_nostr_auth_put(&operator_keys, &upper_path, put_body),
)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(put_body.to_vec()))
.expect("request"),
)
.await;
assert_eq!(
upper.status(),
StatusCode::OK,
"uppercase-path PUT must succeed"
);
let upper_json: serde_json::Value = {
let bytes = axum::body::to_bytes(upper.into_body(), 4096)
.await
.expect("body");
serde_json::from_slice(&bytes).expect("json")
};
assert_eq!(
upper_json["pubkey"], lower_hex,
"uppercase path param must be canonicalized to lowercase in the response"
);
}

#[tokio::test]
#[ignore = "requires Postgres — reopen of an open report is 409"]
async fn reopen_route_rejects_non_terminal_report_with_409() {
Expand Down
2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"lint": "biome lint .",
"check": "biome check . && pnpm check:px-text && pnpm check:pubkey-truncation",
"format": "biome format --write .",
"test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\" \"scripts/*.test.mjs\"",
"test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\" \"scripts/*.test.mjs\" && node --import ./test-jsdom-setup.mjs --import ./test-loader.mjs --experimental-strip-types --test-force-exit --test \"src/**/*.jsdom-test.mjs\"",
"preview": "vite preview",
"tauri": "node ./scripts/tauri-command.mjs",
"test:e2e": "pnpm build:e2e && playwright test",
Expand Down
2 changes: 2 additions & 0 deletions desktop/scripts/check-pubkey-truncation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const overrides = new Set([
"src/features/messages/lib/threadPanel.ts:395",
"src/features/projects/ui/ProjectsView.tsx:166",
"src/features/projects/ui/ProjectsOverviewPanel.tsx:209",
// Error message prefix in a console-internal action error (never rendered as identity).
"src/features/admin-console/AdminConsoleStaffingTab.tsx:108",
]);

await runPubkeyTruncationCheck({
Expand Down
Loading
Loading